자바스크립트에서 Class를 추구하면 안될까?

React.js 이전의 Javascript에서는

DOM에 직접 접근했어요
절차지향적으로 작성했어요
명령형으로 작성했어요
...

아주아주 간단한 쇼핑몰

          
Item Price Quantity Total
Total: $0.00

절차지향적 코드

          
            
            const products = [
            { id: 1, name: 'Product 1', price: 10.00 },
            { id: 2, name: 'Product 2', price: 15.00 },
            { id: 3, name: 'Product 3', price: 20.00 }
            ];
            
            let cart = [];
            
            function updateCartTable() {
            const cartItemsElement = document.getElementById('cart-items');
            cartItemsElement.innerHTML = '';
            
            let totalPrice = 0;
            for (let i = 0; i < cart.length; i++) {
              const item=cart[i];
              const product=products.find(p=> p.id === item.product.id);
              const totalItemPrice = product.price * item.quantity;
              totalPrice += totalItemPrice;
            
              const row = document.createElement('tr');
              const removeButton = document.createElement('button');
              removeButton.textContent = 'Remove';
              removeButton.addEventListener('click', () => removeFromCart(product));

              row.innerHTML = `
              <td>${product.name}</td>
              <td>${product.price.toFixed(2)}</td>
              <td>${item.product.quantity}</td>
              <td>${totalItemPrice.toFixed(2)}</td>
              <td></td>
              `;

              row.lastElementChild.appendChild(removeButton);
        cartItemsElement.appendChild(row);
        }

        document.getElementById('total-price').textContent = `${totalPrice.toFixed(2)}`;
        }

        function addToCart(product, quantity) {
        const existingItem = cart.find(item => item.product.id === product.id);
        if (existingItem) {
        existingItem.quantity += quantity;
        } else {
        cart.push({ product, quantity: 1 });
        }
        updateCartTable();
        }

        function removeFromCart(product) {
        const index = cart.findIndex(item => item.product.id === product.id);
        if (index !== -1) {
        cart.splice(index, 1);
        updateCartTable();
        }
        }

        const productListElement = document.getElementById('product-list');
        
        for (const product of products) {
        const productItem = document.createElement('div');
        productItem.classList.add('product-item');
        
        const productName = document.createElement('h3');
        productName.textContent = product.name;
        
        const productPrice = document.createElement('p');
        productPrice.textContent = `Price: $${product.price.toFixed(2)}`;
        
        const addToCartButton = document.createElement('button');
        addToCartButton.textContent = 'Add to Cart';
        addToCartButton.addEventListener('click', () => addToCart(product, 1));
        
        productItem.appendChild(productName);
        productItem.appendChild(productPrice);
        productItem.appendChild(addToCartButton);
        
        productListElement.appendChild(productItem);
        }
        
        
        

이런 점이 아쉬워요

함수명 선언할 때 반복이 있어요
같은 파일 내의 누구나 변수에 접근할 수 있어요
책임이 명확하지 않아 디버깅이 어려워질 수 있어요

절차지향에서 객체지향으로

프로토타입 활용

          
            
            function Product(id, name, price) {
            this.id = id;
            this.name = name;
            this.price = price;
            }
            
            function Cart() {
            this.items = [];
            }
            
            Cart.prototype.addItem = function(product, quantity) {
            const existingItem = this.items.find(item => item.product.id === product.id);
            if (existingItem) {
            existingItem.quantity += quantity;
            } else {
            this.items.push({ product, quantity });
            }
            this.updateTable();
            };
            
            Cart.prototype.removeItem = function(product) {
            const index = this.items.findIndex(item => item.product.id === product.id);
            if (index !== -1) {
            this.items.splice(index, 1);
            this.updateTable();
            }
            };
            
            Cart.prototype.updateTable = function() {
            const tableBody = document.getElementById('cart-table-body');
            tableBody.innerHTML = '';
            
            for (const { product, quantity } of this.items) {
            const row = document.createElement('tr');
            const removeButton = document.createElement('button');
            removeButton.textContent = 'Remove';
            removeButton.addEventListener('click', () => this.removeItem(product));

            row.innerHTML = `
            <td>${product.name}</td>
            <td>${quantity}</td>
            <td>${product.price * quantity}</td>
            <td></td>
            `;

            row.lastElementChild.appendChild(removeButton);
            tableBody.appendChild(row);
            }
            };

            function ProductListRenderer(cart, products) {
            this.cart = cart;
            this.products = products;
            this.productListElement = document.getElementById('product-list');
            }
            
            ProductListRenderer.prototype.renderProducts = function() {
            for (const product of this.products) {
            const productItem = this.createProductItem(product);
            this.productListElement.appendChild(productItem);
            }
            };
            
            ProductListRenderer.prototype.createProductItem = function(product) {
            const productItem = document.createElement('div');
            productItem.classList.add('product-item');
            
            const productName = document.createElement('h3');
            productName.textContent = product.name;
            
            const productPrice = document.createElement('p');
            productPrice.textContent = `Price: $${product.price.toFixed(2)}`;
            
            const addToCartButton = document.createElement('button');
            addToCartButton.textContent = 'Add to Cart';
            addToCartButton.addEventListener('click', () => this.cart.addItem(product, 1));
            
            productItem.appendChild(productName);
            productItem.appendChild(productPrice);
            productItem.appendChild(addToCartButton);
            
            return productItem;
            };
            
            const products = [
            new Product(1, 'Product 1', 10.99),
            new Product(2, 'Product 2', 15.99),
            new Product(3, 'Product 3', 20.99),
            ];
            
            const cart = new Cart();
            const productListRenderer = new ProductListRenderer(cart, products);
            productListRenderer.renderProducts();
            
          
        

또 아쉬운 점이 있네요!

선언부에 반복이 있어요
어디서든 메서드를 선언할 수 있어요
응집도가 낮아질 수 있어요

Class

          
            
            class Product {
            constructor(id, name, price) {
            this.id = id;
            this.name = name;
            this.price = price;
            }
            }
            
            class Cart {
            constructor() {
            this.items = [];
            }
            
            addItem(product, quantity) {
            const existingItem = this.items.find(item => item.product.id === product.id);
            if (existingItem) {
            existingItem.quantity += quantity;
            } else {
            this.items.push({ product, quantity });
            }
            this.updateCartTable();
            }
            
            removeItem(product) {
            const index = this.items.findIndex(item => item.product.id === product.id);
            if (index !== -1) {
            this.items.splice(index, 1);
            this.updateCartTable();
            }
            }
            
            updateCartTable() {
            const tableBody = document.getElementById('cart-table-body');
            tableBody.innerHTML = '';
            
            for (const { product, quantity } of this.items) {
            const row = document.createElement('tr');
            const removeButton = document.createElement('button');
            removeButton.textContent = 'Remove';
            removeButton.addEventListener('click', () => removeFromCart(product));

            row.innerHTML = `
            <td>${product.name}</td>
            <td>${quantity}</td>
            <td>${product.price * quantity}</td>
            <td></td>
            `;

            row.lastElementChild.appendChild(removeButton);
            tableBody.appendChild(row);
            }
            }
            }

            class ProductListRenderer {
            constructor(cart, products) {
            this.cart = cart;
            this.products = products;
            this.productListElement = document.getElementById('product-list');
            }
            
            renderProducts() {
            for (const product of this.products) {
            const productItem = this.createProductItem(product);
            this.productListElement.appendChild(productItem);
            }
            }
            
            createProductItem(product) {
            const productItem = document.createElement('div');
            productItem.classList.add('product-item');
            
            const productName = document.createElement('h3');
            productName.textContent = product.name;
            
            const productPrice = document.createElement('p');
            productPrice.textContent = `Price: $${product.price.toFixed(2)}`;
            
            const addToCartButton = document.createElement('button');
            addToCartButton.textContent = 'Add to Cart';
            addToCartButton.addEventListener('click', () => this.cart.addItem(product, 1));
            
            productItem.appendChild(productName);
            productItem.appendChild(productPrice);
            productItem.appendChild(addToCartButton);
            
            return productItem;
            }
            }
            
            const products = [
            new Product(1, 'Product 1', 10.99),
            new Product(2, 'Product 2', 15.99),
            new Product(3, 'Product 3', 20.99),
            ];
            
            const cart = new Cart();
            const productListRenderer = new ProductListRenderer(cart, products);
            productListRenderer.renderProducts();
            
          
        

명령형 코드에 전반적으로 아쉬운 점이 있어요

코드를 재사용하기가 어려워요
DOM을 직접 조작하는 과정에서 코드가 복잡해져요
변수의 상태를 관리하기 어려워요

명령형에서 선언형으로

React.js Class 컴포넌트

          
            
              import React, { Component } from 'react';
              
              class Product {
              constructor(id, name, price) {
              this.id = id;
              this.name = name;
              this.price = price;
              }
              }
              
              class ProductListItem extends Component {
              handleAddToCart = () => {
              this.props.onAddToCart(this.props.product, 1);
              }
              
              render() {
              const { product } = this.props;
              return (
              <div>
                <h3>{product.name}</h3>
                <p>Price: ${product.price.toFixed(2)}</p>
                <button onClick={this.handleAddToCart}>Add to Cart</button>
              </div>
              );
              }
              }
              
              class ProductList extends Component {
              render() {
              const { products, onAddToCart } = this.props;
              return (
              <div>
                {products.map(product => (
                <ProductListItem key={product.id} product={product} onAddToCart={onAddToCart} />
                ))}
              </div>
              );
              }
              }
              
              class Cart extends Component {
              render() {
              const { items, onRemoveItem } = this.props;
              return (
              <table>
                <thead>
                  <tr>
                    <th>Product</th>
                    <th>Quantity</th>
                    <th>Total</th>
                    <th>Action</th>
                  </tr>
                </thead>
                <tbody>
                  {items.map(({ product, quantity }) => (
                  <tr key={product.id}>
                    <td>{product.name}</td>
                    <td>{quantity}</td>
                    <td>{product.price * quantity}</td>
                    <td>
                      <button onClick={()=> onRemoveItem(product)}>Remove</button>
                    </td>
                  </tr>
                  ))}
                </tbody>
              </table>
              );
              }
              }
              
              class App extends Component {
              constructor(props) {
              super(props);
              this.state = {
              products: [
              new Product(1, 'Product 1', 10.99),
              new Product(2, 'Product 2', 15.99),
              new Product(3, 'Product 3', 20.99),
              ],
              cart: {
              items: []
              }
              };
              }
              
              handleAddToCart = (product, quantity) => {
              const existingItem = this.state.cart.items.find(item => item.product.id === product.id);
              if (existingItem) {
              existingItem.quantity += quantity;
              } else {
              this.setState(prevState => ({
              cart: {
              items: [...prevState.cart.items, { product, quantity }]
              }
              }));
              }
              }
              
              handleRemoveItem = (product) => {
              this.setState(prevState => ({
              cart: {
              items: prevState.cart.items.filter(item => item.product.id !== product.id)
              }
              }));
              }
              
              render() {
              return (
              <div>
                <ProductList products={this.state.products} onAddToCart={this.handleAddToCart} />
                <Cart items={this.state.cart.items} onRemoveItem={this.handleRemoveItem} />
              </div>
              );
              }
              }
              
              export default App;
            
          
        

클래스 컴포넌트 특유의 보일러플레이트 코드가 생겼어요!

React.js Function 컴포넌트

          
            
              import React, { useState } from 'react';
              
              class Product {
              constructor(id, name, price) {
              this.id = id;
              this.name = name;
              this.price = price;
              }
              }
              
              const ProductListItem = ({ product, onAddToCart }) => {
              const handleAddToCart = () => {
              onAddToCart(product, 1);
              };
              
              return (
              <div>
                <h3>{product.name}</h3>
                <p>Price: ${product.price.toFixed(2)}</p>
                <button onClick={handleAddToCart}>Add to Cart</button>
              </div>
              );
              };
              
              const ProductList = ({ products, onAddToCart }) => {
              return (
              <div>
                {products.map(product => (
                <ProductListItem key={product.id} product={product} onAddToCart={onAddToCart} />
                ))}
              </div>
              );
              };
              
              const Cart = ({ items, onRemoveItem }) => {
              return (
              <table>
                <thead>
                  <tr>
                    <th>Product</th>
                    <th>Quantity</th>
                    <th>Total</th>
                    <th>Action</th>
                  </tr>
                </thead>
                <tbody>
                  {items.map(({ product, quantity }) => (
                  <tr key={product.id}>
                    <td>{product.name}</td>
                    <td>{quantity}</td>
                    <td>{product.price * quantity}</td>
                    <td>
                      <button onClick={()=> onRemoveItem(product)}>Remove</button>
                    </td>
                  </tr>
                  ))}
                </tbody>
              </table>
              );
              };
              
              const App = () => {
              const [products, setProducts] = useState([
              new Product(1, 'Product 1', 10.99),
              new Product(2, 'Product 2', 15.99),
              new Product(3, 'Product 3', 20.99),
              ]);
              
              const [cart, setCart] = useState({
              items: [],
              });
              
              const handleAddToCart = (product, quantity) => {
              const existingItem = cart.items.find(item => item.product.id === product.id);
              if (existingItem) {
              existingItem.quantity += quantity;
              setCart(prevState => ({
              items: [...prevState.items],
              }));
              } else {
              setCart(prevState => ({
              items: [...prevState.items, { product, quantity }],
              }));
              }
              };
              
              const handleRemoveItem = product => {
              setCart(prevState => ({
              items: prevState.items.filter(item => item.product.id !== product.id),
              }));
              };
              
              return (
              <div>
                <ProductList products={products} onAddToCart={handleAddToCart} />
                <Cart items={cart.items} onRemoveItem={handleRemoveItem} />
              </div>
              );
              };
              
              export default App;
            
          
        

좀 더 살펴보면은...

부모 컴포넌트에 상태와 로직이 종속적이에요
상품 정보나 장바구니 정보를 사용하는 곳에서 재사용하기가 어려워졌어요

useSyncExternalStore

          
            
              class ProductStore {
              static instance = null;
              
              constructor() {
              if (ProductStore.instance) {
              return ProductStore.instance;
              }
              
              this.products = [
              { id: 1, name: 'Product 1', price: 10 },
              { id: 2, name: 'Product 2', price: 20 },
              { id: 3, name: 'Product 3', price: 30 },
              ];
              this.cartItems = [];
              this.subscribers = new Set();
              
              ProductStore.instance = this;
              }
              
              getProducts() {
              return this.products;
              }
              
              getCartItems() {
              return this.cartItems;
              }
              
              addToCart(product, quantity) {
              const existingItem = this.cartItems.find((item) => item.product.id === product.id);
              if (existingItem) {
              existingItem.quantity += quantity;
              } else {
              this.cartItems.push({ product, quantity });
              }
              this.notifySubscribers();
              }
              
              removeFromCart(product) {
              const itemIndex = this.cartItems.findIndex((item) => item.product.id === product.id);
              if (itemIndex !== -1) {
              this.cartItems.splice(itemIndex, 1);
              this.notifySubscribers();
              }
              }
              
              subscribe(callback) {
              this.subscribers.add(callback);
              return () => this.unsubscribe(callback);
              }
              
              unsubscribe(callback) {
              this.subscribers.delete(callback);
              }
              
              notifySubscribers() {
              this.subscribers.forEach((subscriber) => subscriber());
              }
              }
              
              const ProductList = () => {
              const store = new ProductStore();
              const products = useSyncExternalStore(store.subscribe, store.getProducts);
              
              const handleAddToCart = (product, quantity) => {
              store.addToCart(product, quantity);
              };
              
              return (
              <div>
                {products.map((product) => (
                <ProductListItem key={product.id} product={product} onAddToCart={handleAddToCart} />
                ))}
              </div>
              );
              };
              
              const ProductListItem = ({ product, onAddToCart }) => {
              const handleAddToCart = () => {
              onAddToCart(product, 1);
              };
              
              return (
              <div>
                <h3>{product.name}</h3>
                <p>Price: ${product.price}</p>
                <button onClick={handleAddToCart}>Add to Cart</button>
              </div>
              );
              };
              
              const Cart = () => {
              const store = new ProductStore();
              const cartItems = useSyncExternalStore(store.subscribe, store.getCartItems);
              
              const handleRemoveFromCart = (product) => {
              store.removeFromCart(product);
              };
              
              return (
              <table>
                <thead>
                  <tr>
                    <th>Product</th>
                    <th>Quantity</th>
                    <th>Price</th>
                    <th>Actions</th>
                  </tr>
                </thead>
                <tbody>
                  {cartItems.map((item, index) => (
                  <tr key={index}>
                    <td>{item.product.name}</td>
                    <td>{item.quantity}</td>
                    <td>{item.product.price * item.quantity}</td>
                    <td>
                      <button onClick={()=> handleRemoveFromCart(item.product)}>
                        Remove
                      </button>
                    </td>
                  </tr>
                  ))}
                </tbody>
              </table>
              );
              };
              
              const App = () => {
              return (
              <>
                <ProductList />
                <Cart />
              </>
              );
              };
              
              export default App;
            
          
        

구독을 관리하는 부분에 보일러플레이트 코드가 생겼네요!

Valtio

                
                  
                    import { proxy, useSnapshot } from 'valtio';
                    
                    class ProductStore {
                    static instance = null;
                    
                    state = proxy({
                    products: [
                    { id: 1, name: 'Product 1', price: 10 },
                    { id: 2, name: 'Product 2', price: 20 },
                    { id: 3, name: 'Product 3', price: 30 },
                    ],
                    cartItems: [],
                    });
                    
                    static getInstance() {
                    if (!ProductStore.instance) {
                    ProductStore.instance = new ProductStore();
                    }
                    return ProductStore.instance;
                    }
                    
                    addToCart(product, quantity) {
                    const existingItem = this.state.cartItems.find((item) => item.product.id === product.id);
                    if (existingItem) {
                    existingItem.quantity += quantity;
                    } else {
                    this.state.cartItems.push({ product, quantity });
                    }
                    }
                    
                    removeFromCart(product) {
                    const itemIndex = this.state.cartItems.findIndex((item) => item.product.id === product.id);
                    if (itemIndex !== -1) {
                    this.state.cartItems.splice(itemIndex, 1);
                    }
                    }
                    }
                    
                    const ProductList = () => {
                    const store = ProductStore.getInstance();
                    const { products } = useSnapshot(store.state);
                    
                    const handleAddToCart = (product, quantity) => {
                    store.addToCart(product, quantity);
                    };
                    
                    return (
                    <div>
                      {products.map((product) => (
                      <ProductListItem key={product.id} product={product} onAddToCart={handleAddToCart} />
                      ))}
                    </div>
                    );
                    };
                    
                    const ProductListItem = ({ product, onAddToCart }) => {
                    const handleAddToCart = () => {
                    onAddToCart(product, 1);
                    };
                    
                    return (
                    <div>
                      <h3>{product.name}</h3>
                      <p>Price: ${product.price}</p>
                      <button onClick={handleAddToCart}>Add to Cart</button>
                    </div>
                    );
                    };
                    
                    const Cart = () => {
                    const store = ProductStore.getInstance();
                    const { cartItems } = useSnapshot(store.state);
                    
                    const handleRemoveFromCart = (product) => {
                    store.removeFromCart(product);
                    };
                    
                    return (
                    <table>
                      <thead>
                        <tr>
                          <th>Product</th>
                          <th>Quantity</th>
                          <th>Price</th>
                          <th>Actions</th>
                        </tr>
                      </thead>
                      <tbody>
                        {cartItems.map((item, index) => (
                        <tr key={index}>
                          <td>{item.product.name}</td>
                          <td>{item.quantity}</td>
                          <td>{item.product.price * item.quantity}</td>
                          <td>
                            <button onClick={()=> handleRemoveFromCart(item.product)}>Remove</button>
                          </td>
                        </tr>
                        ))}
                      </tbody>
                    </table>
                    );
                    };
                    
                    const App = () => {
                    return (
                    <>
                      <ProductList />
                      <Cart />
                    </>
                    );
                    };
                    
                    export default App;
                  
                
              

정리를 해보자면!

함수명 선언할 때 반복이 있어요
같은 파일 내의 누구나 변수에 접근할 수 있어요
책임이 명확하지 않아 디버깅이 어려워질 수 있어요
절차 지향 -> 객체 지향
선언부에 반복이 있어요
어디서든 메서드를 선언할 수 있어요
응집도가 낮아질 수 있어요
명령형 -> 선언형
부모 컴포넌트에 상태와 로직이 종속적이에요
다시 재사용하기가 어려워졌어요
내부상태 -> 전역상태

자바스크립트에서 Class를 추구해도 된다!