1990년대 에 만들어진 디자인 패턴
디자인 패턴에 대한 오해

 

5주차는 그동안 가졌던 편견이 많이 깨지는 시간이었습니다.

소프트웨어에는 다양한 디자인 패턴이 존재합니다. 싱글톤, 옵저버, MVC, MVVM 등등..

그런데 지금까지 실무에서 사용한적이 없고 필요가 없었으니 반쪽짜리 이해만 한 상태였습니다.

 

애초에 디자인패턴은 객체지향 언어가 주류였을 시절 여러 문제들을 해결하기 위해 나왔었고 

함수형 프로그래밍이 가능한 javascript 언어의 특성상 20년도 전에 만들어진 디자인 패턴들을 사용할 일이 많이 없었습니다.

 

게다가 React 가 많은 것들을 추상화 레이어로 한단계 가려주니 레이어 안쪽에서 여러 디자인 패턴들을 사용하는 것과 별개로

hooks와 커스텀hook 만으로 충분히 개발이 가능했습니다. 

 

그럼 전통적인 디자인패턴은 배울 필요가 없는건가?

 

직접 사용하지 않는 것과 별개로 우리가 사용하는 대부분의 라이브러리 내부에는 이러한 패턴들이 감추어져 있습니다. 

우리가 '딸깍'으로 만든 전역 상태의 깊은 곳에선 싱글톤 패턴과 옵저버 패턴의 조합이 있었습니다.

 

그리고 개발자간 커뮤니케이션을 할 때도 필요합니다.

디자인패턴을 알아야 기능의 설명이 아닌 개념의 전달이 된다고 생각합니다.

"하나의 스토어를 두고 해당 스토어를 구독을해서 구독을 하고있는 모든 곳에서 변경 사항을 감지하게 했어요" 를
=> "옵저버 패턴을 썼어요"  로 간단히 설명할 수 있습니다.

 

 

프론트엔드가 진짜 배워야 할 패턴들

 

1. 함수형 프로그래밍

함수형 프로그래밍은 순수 함수, 불변성, 선언적 프로그래밍 등의 개념을 중심으로 하는 프로그래밍 패러다임입니다. 이는 코드의 예측 가능성과 테스트 용이성을 높입니다.

 

2. 컴포넌트 기반 아키텍쳐

UI를 재사용 가능한 독립적인 부분들로 나누는 설계 방식으로, 현대 프론트엔드 프레임워크의 핵심 개념입니다.

 

3. 상태 관리 패턴

애플리케이션의 데이터 흐름을 관리하는 방식으로, Context Api, Redux, Zustand 등의 라이브러리가 이를 구현합니다.

 

4. 비동기 프로그래밍 패턴

Promise, async/await 등 비동기 작업을 효과적으로 다루는 패턴들입니다. 

 

 

함수형 프로그래밍과 개발의 상관관계

 

함수형 프로그래밍이 뭔데요? 그거 하면 개발 잘하나요? 

 

함수형 프로그래밍은 프로그램을 더 간단하고, 예측 가능하며, 테스트하기 쉽게 만드는 것을 목표로합니다.

위의 목표를 향해 조금 더 딥 다이브 해보겠습니다. 

 

1. 코드를 데이터 / 계산 / 액션으로 분류해야합니다.

const 유저데이터 = {
  name: "김항해",
  age: 7
};

const 계산함수 = (a, b) => {
  return a + b;

  // 동일한 input에 대해 동일한 output을 보장합니다 (*반드시 input과 output이 있습니다)
  // 외부 상태에 의존하지 않고, 실행 시점에 관계없이 결과가 동일합니다.
};

const [name, setName] = useState("김항해")
const 액션함수 = () => {
  setName('양금명');

  // 외부 세계와 상호작용 하는 함수입니다
  // side effect를 발생시키거나 외부 상태에 의존합니다
  // 실행 시점과 환경에 따라 결과가 달라질 수 있습니다
  // 예: 파일 읽기/쓰기, DB 조회/수정, setState 등 
}

 

데이터는 구분하기 쉽습니다. 하지만 계산함수와 액션함수는 그렇지 않은것 같습니다.

 

2. 계산을 분리해서 액션을 최대한 작게 만드는 것이 핵심입니다

예시를 통해 액션에서 계산을 분리해보겠습니다.

/// 예시 코드
var shopping_cart = [];
var shopping_cart_total = 0;

// 액션 함수
function add_item_to_cart(name, price) {
  // 외부 환경을 (전역 변수) 변경시키니 액션입니다
  shopping_cart.push({
    name: name,
    price: price,
  });
}

// 액션 함수
function calc_cart_total() {
  // 외부 환경을 (전역 변수) 변경시키니 액션입니다
  shopping_cart_total = 0;
  for (var i = 0; i < shopping_cart.length; i++) {
    var item = shopping_cart[i];
    shopping_cart_total += item.price;
  }

  // 외부 환경을 변경시키니 액션입니다
  set_cart_total_dom();
}

 

모든 함수에서 side effect가 발생해 테스트하기 어렵습니다.

계산함수를 최대한 분리하고 액션함수를 작게 만들어보겠습니다.

// 액션 함수 덩어리를 (계산 -> 액션) 으로 나누었습니다
function add_item_to_cart(cart, name, price) {
  const newCart = get_added_cart(cart, name, price);

  setCart(newCart); // 이 함수가 종착지라면 액션을 호출합니다.
}


// get_added_cart는 계산함수여서 테스트 가능합니다🎉
const get_added_cart = (cart, name, price) => {
  return [
    ...cart,
    {
      name: name,
      price: price,
    },
  ];
};

// 액션 함수 덩어리를 (계산 -> 액션) 으로 나누었습니다
function calc_cart_total(cart) {
  const shopping_cart_total = calc_total_price(cart);

  set_cart_total_dom(shopping_cart_total);
}

// calc_total_price는 계산함수여서 테스트 가능합니다🎉
function calc_total_price(cart) {
  return cart.reduce((val, item) => {
    val + item.price;
  }, 0);
}

 

get_added_cart 함수

1. cart를 인자로 받아 불변성을 유지해 외부환경에 영향을 주지 않습니다
2. 동일한 input에 동일한 output이 나옵니다

 

calc_total_price 함수
1. 명령형인 for문 대신 *고차 함수(선언적) reduce로 작성해 코드를 추상화합니다

2. 동일한 input에 동일한 output이 나옵니다
* 함수형 프로그래밍에선 고차 함수 (map, filter, reduce 등)를 사용하는 방식이 선호됩니다.

 

 

함수형 프로그래밍을 적용하니 테스트 가능한 계산함수가 많아졌고 그만큼 액션함수가 작아졌습니다.

side effect가 적어져 보다 안정적인 환경에서 프로그래밍을 할 수 있게 되었습니다🥳

 

  

이번엔 함수형 프로그래밍을 리액트에 적용해보겠습니다.

장바구니에 관련된 컴포넌트입니다. 클릭한 상품을 카트에 넣어주는 함수가 있네요.

export const CartPage = () => {
    const [cart, setCart] = useState([]);
    
    const addToCart = (product) => {
    	setCart(prevCart => {
          const existingItem = prevCart.find(item => item.product.id === product.id);
          if (existingItem) {
            return prevCart.map(item =>
              item.product.id === product.id
                ? { ...item, quantity: Math.min(item.quantity + 1, product.stock) }
                : item
            );
          }
          return [...prevCart, { product, quantity: 1 }];
        });
    }

	return (
    	<div>
        	<button onClick={() => addToCart(product)}></button>
            
            ... 나머지 코드
        </div>
    )
}

 

 

setCart 가 외부환경에 변화를 주기 때문에 addToCart는 액션함수입니다.

그런데 순수함수로 추출 할 수 있는 부분이 있는것 같습니다.

 

// 액션 함수 덩어리를 (계산 -> 액션) 으로 나누었습니다
const addToCart = (product: Product) => {
  setCart((prev) => {
    return getAddedToCart(prev, product);
  });
};

// getProductIndexInCart는 계산함수여서 테스트 가능합니다🎉
const getProductIndexInCart = (cart: CartItem[], productId: string) => {
  return cart.findIndex((item) => item.product.id === productId);
}

// getAddedToCart는 계산함수여서 테스트 가능합니다🎉
const getAddedToCart = (cart: CartItem[], product: Product) => {
  const targetIndex = getProductIndexInCart(cart, product.id);

  if (targetIndex === -1) {
    return [...cart, { product, quantity: 1 }];
  }

  return cart.map((item, index) =>
    index === targetIndex ? { ...item, quantity: item.quantity + 1 } : item
  );
};

 

 

액션이 작아진건 좋은데 코드양이 더 늘어나서 보기가 좋지 않습니다.

커스텀 훅으로 관심사를 분리하면 좋을것 같습니다.

 

export const CartPage = () => {
  const {cart, addToCart} = useCart();

  return (
    <div>
        <button onClick={() => addToCart(product)}></button>

        ... 나머지 코드
    </div>
  )
}

 

컴포넌트가 클린해 졌네요.

 

계층형 설계 (aka. 추상화 레이어 )

 

함수형 프로그래밍에는 계층형 설계라는 내용이 있습니다.

코드를 추상화 계층으로 구성해서 각 계층을 볼 때 다른 계층의 구체적인 내용은 몰라도 됩니다.

위의 커스텀 훅은 계층형 설계입니다.

 

useCart 를 모듈화 시키고 cart, addToCart만 인터페이스로 제공했습니다.

컴포넌트 계층에선 addToCart 가 어떻게 동작하는지 몰라도 됩니다. 그냥 사용해서 카트에 물건을 담기만 하면 됩니다.

다만, 인터페이스만 제공되니 함수 이름을 잘 지어야합니다.

 

이렇게 이번 5주차에는 디자인 패턴과 함수형 프로그로밍에 대해서 알아봤습니다.

테오 코치가 추천해준 <쏙쏙 들어오는 함수형 코딩> 을 읽었더니 이번 회고는 뭔가 학습지처럼 진행이 되어버렸네요🤔 


함수형 프로그래밍에 대한 내용만 다루었지만 자바스크립트는 많은 부분 객체지향 언어의 특징을 가집니다.

리액트도 얼마전까지 객체지향인 클래스 컴포넌트를 사용했으니 지금 사용하지 않더라도

알아두는게 좋지 않을까 생각됩니다 (뭐.. 클래스 컴포넌트를 유지보수 할 일이 있을수도 있잖아요..?)

 

여러가지를 배웠지만 핵심은 "액션을 작게 만들자" 인거 같습니다.

사실 <쏙쏙>만 읽어서는 리액트에 적용하기 쉽지 않았을거 같은데 항해 커리큘럼의 도움으로 리액트에 적용하는 부분까지

큰 어려움 없이 해낸것 같습니다. 

 

그럼 과제하면서 추가로 알게된 내용을 끝으로 회고 마칩니다🌠

출처: https://kofearticle.substack.com/p/korean-fe-article-771

 

setState 사용법
setState 사용에는 함수형 업데이트 방식이 있습니다.

위의 내용 중
const addToCart = (product: Product) => {
  setCart((prev) => getAddedToCart(prev, product));
};
가 그렇습니다.

아래 코드처럼 작성했다고 가정해보겠습니다.
이 코드도 동일한 기능을 합니다.
const [cart, setCart] = useState([]);

const addToCart = (product: Product) => {
  const newCart = getAddedToCart(cart, product);
  setCart(newCart);
};

다만, 이 코드는 버그가 생길 여지가 있습니다.
React의 상태 업데이트가 비동기적으로 처리되어 빠르게 여러번 업데이트가 되거나
다른 이벤트 처리 중에 상태 업데이트가 일어나면, 클로저에 갇힌 cart 변수는 최신 상태를 반영하지 못할 수 있습니다.

이때 함수형 업데이트 setCart((prev) => ...) 를 사용하면 React는 항상 최신 상태값을 함수에 전달하므로 이런 문제를 방지할 수 있습니다.

실제로 수동 테스트로는 문제가 없었지만 테스트 코드를 통해 addToCart 를 테스트 해보니 
cart가 최신 상태를 반영하지 못하는 버그가 있었습니다. 

 

이번에는 냅다 더티코드가 주어지고 더티코드를 클린코드로 바꾸는 주차였다.

어떤게 클린코드인지 왜 클린코드인지에 대해 고민하는 시간이었다.

 

 

// 과제 일부 발췌

var prodList, sel, addBtn, cartDisp, sum, stockInfo;
var lastSel, bonusPts=0, totalAmt=0, itemCnt=0;
function main() {
  prodList=[
    {id: 'p1', name: '상품1', val: 10000, q: 50 },
    {id: 'p2', name: '상품2', val: 20000, q: 30 },
  ];

  var root=document.getElementById('app');
  let cont=document.createElement('div');
  cartDisp.id='cart-items';
  sum.id='cart-total';

  stockInfo.id='stock-status';
  wrap.className='max-w-md mx-auto bg-white rounded-xl shadow-md overflow-hidden md:max-w-2xl p-8';
  hTxt.className='text-2xl font-bold mb-4';
  hTxt.textContent='장바구니';
  addBtn.textContent='추가';
  updateSelOpts();
  wrap.appendChild(hTxt);
  wrap.appendChild(addBtn);
  root.appendChild(cont);
  calcCart();
  setTimeout(function () {
    setInterval(function () {
      var luckyItem=prodList[Math.floor(Math.random() * prodList.length)];
      if(Math.random() < 0.3 && luckyItem.q > 0) {
        luckyItem.val=Math.round(luckyItem.val * 0.8);
        alert('번개세일! ' + luckyItem.name + '이(가) 20% 할인 중입니다!');
        updateSelOpts();

      }
    }, 30000);
  }, Math.random() * 10000);

function calcCart() {
  totalAmt=0;
  itemCnt=0;
  var cartItems=cartDisp.children;
  var subTot=0;
  for (var i=0; i < cartItems.length; i++) {
    (function () {
      var curItem;
      for (var j=0; j < prodList.length; j++) {
        if(prodList[j].id === cartItems[i].id) {
          curItem=prodList[j];
          break;
        }
      }

      var q=parseInt(cartItems[i].querySelector('span').textContent.split('x ')[1]);
      var itemTot=curItem.val * q;
      var disc=0;
      itemCnt += q;
      subTot += itemTot;
      if(q >= 10) {
        if(curItem.id === 'p1') disc=0.1;
        else if(curItem.id === 'p2') disc=0.15;
        else if(curItem.id === 'p3') disc=0.2;
        else if(curItem.id === 'p4') disc=0.05;
        else if(curItem.id === 'p5') disc=0.25;
      }
      totalAmt += itemTot * (1 - disc);
    })();
  }
  let discRate=0;
  if(itemCnt >= 30) {
    var bulkDisc=totalAmt * 0.25;
    var itemDisc=subTot - totalAmt;
    if(bulkDisc > itemDisc) {
      totalAmt=subTot * (1 - 0.25);
      discRate=0.25;
    } else {
      discRate=(subTot - totalAmt) / subTot;
    }
  } else {
    discRate=(subTot - totalAmt) / subTot;
  }

  if(new Date().getDay() === 2) {
    totalAmt *= (1 - 0.1);
    discRate=Math.max(discRate, 0.1);
  }
  sum.textContent='총액: ' + Math.round(totalAmt) + '원';
  if(discRate > 0) {
    var span=document.createElement('span');
    span.className='text-green-500 ml-2';
    span.textContent='(' + (discRate * 100).toFixed(1) + '% 할인 적용)';
    sum.appendChild(span);
  }
  updateStockInfo();
  renderBonusPts();
}

main();
addBtn.addEventListener('click', function () {
  var selItem=sel.value;
  var itemToAdd=prodList.find(function (p) { return p.id === selItem; });
  if(itemToAdd && itemToAdd.q > 0) {
    var item=document.getElementById(itemToAdd.id);
    if(item) {
      var newQty=parseInt(item.querySelector('span').textContent.split('x ')[1]) + 1;
      if(newQty <= itemToAdd.q) {
        item.querySelector('span').textContent=itemToAdd.name + ' - ' + itemToAdd.val + '원 x ' + newQty;
        itemToAdd.q--;
      } else {
        alert('재고가 부족합니다.');
      }
    } else {
      var newItem=document.createElement('div');
      newItem.id=itemToAdd.id;
      newItem.className='flex justify-between items-center mb-2';
      newItem.innerHTML='<span>' + itemToAdd.name + ' - ' + itemToAdd.val + '원 x 1</span><div>' +
        '<button class="quantity-change bg-blue-500 text-white px-2 py-1 rounded mr-1" data-product-id="' + itemToAdd.id + '" data-change="-1">-</button>' +
        '<button class="quantity-change bg-blue-500 text-white px-2 py-1 rounded mr-1" data-product-id="' + itemToAdd.id + '" data-change="1">+</button>' +
        '<button class="remove-item bg-red-500 text-white px-2 py-1 rounded" data-product-id="' + itemToAdd.id + '">삭제</button></div>';
      cartDisp.appendChild(newItem);
      itemToAdd.q--;
    }
    calcCart();
    lastSel=selItem;
  }
});

 

처음 더티코드가 주어졌을 때 막막했다.
중복되고 주석과 들여쓰기가 불규칙적인데다 조건문도 매우매우 복잡하고, 무엇보다 네이밍센스가 말이 안됐다.
(ex. sel, cartDisp 등 무슨 내용의 변수인지 파악하기가 힘들다)

상상 이상의 코드에 뭐부터 해야 좋을지 과제 체크리스트를 살펴봤다

1. 의미 있는 이름 사용
2. 함수 추출 (단일 책임)
3. 매직 넘버 제거
4. 조건문 단순화

... 기타 등등

1. 의미 있는 이름 사용
참고 문서도 좀 읽어보다가 심각한 함수명 & 변수명부터 수술에 들어갔다
그동안은 크게 고민안하고 이름을 지었는데 '클린코드' 란 명목으로 이름을 지으려니 고려해야될게 생각보다 많았다
내가 그동안 사용했던 이름들은 나의 주관이 강하게 반영된 내가 알아보기 쉬운 이름이었다. 

어떤 이름이 좋은 것인가 찾아보던 중 
참 와닿은 내용이 있어서 발췌한다.

 좋은 것은 특이한게 아니라 보편적이라서 좋은 것이다. 단, 그 의미를 분명하게 알고 있어야 한다  -테오


그럼 어떤게 보편적인가?
무언가를 배열에 넣는 함수는 add를 써야하는가 push를 써야하는가? insert는 안되나?
addProduct vs pushProduct vs insertProduct

나는 느낌적으로 addProduct가 맞는거 같다. 

팀별로 변수명을 논의했는데 아래 변수명을 뭘로 바꿀지였다.

interface UserInfoData {
    name: string; 
    age: number; 
    activeStatusFlag: boolean; 
    value: string; 
    detailsString: string;
}


UserInfo, UserData, User 정도 안건이 나왔는데 나름 타당한 이유들을 갖고있다
User) 간단하게 User로만 지어도 명확하다. 짧은게 좋다. 
UserInfo) 그냥 User는 컴포넌트 이름과 겹치는 경우가 가끔있다 그럴때 User as  를 하느니 UserInfo로 하자
UserData) 그동안 컨벤션을 UserData로 가져가서 이게 더 맞는 느낌이다.
  
별거아닌 변수명에도 다양한 의견이 있었고 정말 '느낌'이 정답인 영역이었다.
그래서 합의의 과정이 필요했고 우리가 정한 roll대로 가는것이 정답이었다.
실제로 조금 안맞는 이름도 일관되게 적용해보고 아 이거 이래서 불편하네 싶은것들을 아는것도 클린코드의 과정이라고 그러더라 (4주차 끝나갈때쯤 들었다..)

2. 함수 추출 (단일 책임)
이전에는 리팩토링을 할 때 특히, 하나의 함수를 추출해서 단일 책임의 여러함수로 만들 때 내용이 파편화되고 가독성이 오히려 떨어진다고 생각한 적이 있었는데 이번 세션을 통해서 그 생각이 많이 깨졌다.
함수 추출에 layer를 고려해 단계별로 추상화를 통해 더티 코드가 상당히 읽기 좋은 코드가되었다.

 

3. 매직 넘버 제거

상품 별로 적용되는 할인율, 대량 구매 시 할인 (대량 구매의 수량) 등을 이해할 수 있는 변수로 분류했다

export const DISCOUNT_RATE = {
  p1: 0.1,
  p2: 0.15,
  p3: 0.2,
  p4: 0.05,
  p5: 0.25,
};

export const NUMBER_OF_BULK = 30;

// productId를 변수로 받아 할인율 return 
function getDiscountRate(productId) {
  return DISCOUNT_RATE[productId] || 0;
}

// bulk 수량인 경우 bulk 할인율 적용
if (quantity >= NUMBER_OF_BULK) { // NUMBER_OF_BULK: number
	discountRate = calculateBulkDiscountRate(quantity, discountedPrice, price);
}

 

 


아키텍쳐 적용
리팩토링을 어느정도 적용하고 1~2주차에 진행했던 컴포넌트 기반 아키텍쳐를 적용했다.

// App.js

export class AppComponent {
  // private 변수 선언
  #cartBox;
  #totalPrice;
  #select;
  #addBtn;
  #stockStatus;
  #container;
  #wrap;
  #h1;

  constructor(rootId) {
    this.rootId = rootId;

    // element 변수 선언
    this.#cartBox;
    this.#totalPrice;
    this.#select;
    this.#addBtn;
    this.#stockStatus;
    this.#container;
    this.#wrap;
    this.#h1;

    // 마지막 선택한 상품 id
    this.lastSelectedProductId;
  }

  // DOM 요소 생성
  #createElements() {
    this.#cartBox = document.createElement("div");
    this.#totalPrice = document.createElement("div");
    this.#select = document.createElement("select");
    this.#addBtn = document.createElement("button");
    this.#stockStatus = document.createElement("div");
    this.#container = document.createElement("div");
    this.#wrap = document.createElement("div");
    this.#h1 = document.createElement("h1");
  }

  // DOM 요소 textContent 설정
  #setTextContent() {
    this.#totalPrice.textContent = "총액: 0원(포인트: 0)";
    this.#h1.textContent = "장바구니";
  }

  // DOM 요소 property 설정
  #setProperties() {
    this.#cartBox.id = "cart-items";
    this.#totalPrice.id = "cart-total";
    this.#select.id = "product-select";
    this.#addBtn.id = "add-to-cart";
    this.#stockStatus.id = "stock-status";
    this.#totalPrice.className = "text-xl font-bold my-4";
    this.#select.className = "border rounded p-2 mr-2";
    this.#addBtn.className = "bg-blue-500 text-white px-4 py-2 rounded";
    this.#stockStatus.className = "text-sm text-gray-500 mt-2";
    this.#addBtn.textContent = "추가";

    this.#container.className = "bg-gray-100 p-8";
    this.#wrap.className =
      "max-w-md mx-auto bg-white rounded-xl shadow-md overflow-hidden md:max-w-2xl p-8";
    this.#h1.className = "text-2xl font-bold mb-4";
  }

  // 상품 select 옵션 설정
  #setSelectOptions() {
    this.#select.innerHTML = "";
    productList.forEach((item) => {
      const $opt = document.createElement("option");
      $opt.value = item.id;
      $opt.textContent = item.name + " - " + item.val + "원";
      if (item.quantity === 0) $opt.disabled = true;
      this.#select.appendChild($opt);
    });
  }

  // DOM 요소 appendChild 설정
  #appendChilds() {
    this.#wrap.appendChild(this.#h1);
    this.#wrap.appendChild(this.#cartBox);
    this.#wrap.appendChild(this.#totalPrice);
    this.#wrap.appendChild(this.#select);
    this.#wrap.appendChild(this.#addBtn);
    this.#wrap.appendChild(this.#stockStatus);
    this.#container.appendChild(this.#wrap);

    const $root = document.getElementById(this.rootId);
    $root.appendChild(this.#container);
  }

  // 이벤트 설정
  #setEvent() {
    // 상품 추가 버튼 이벤트
    this.#addBtn.addEventListener("click", () => {
    	//생략
    });

    // 상품 + - 버튼 이벤트 (수량 변경)
    this.#cartBox.addEventListener("click", (event) => {
    	//생략
    });

    // 장바구니 삭제 버튼 이벤트
    this.#cartBox.addEventListener("click", (event) => {
    	//생략
    });
  }

  // AppComponent 렌더링
  render() {
    this.#createElements();
    this.#setProperties();
    this.#setTextContent();
    this.#setSelectOptions();
    this.#appendChilds();

    // DOM 요소 생성 후 이벤트 설정
    this.#setEvent();
  }
}

 

// main.js

const App = new AppComponent("app");

function init() {
  App.render();
}

init();


 1~2주차에서 배운 컴포넌트 아키텍처를 이런식으로 사용하는구나 싶어 좀 뿌듯했다

'하지만 현업에서 이런 코드 안쓰지않나..' 싶은 생각이 들때즈음 기본과제가 끝났고 

심화과제는 위 내용을 고대로 React & Typescript 스펙으로 마이그레이션 하는 내용이었다. 

 

기능 자체는 별게 없는데..

기존의 HTML 템플릿을 리액트 컴포넌트로 바꾸고 이벤트 핸들러를 리액트 방식으로 변경하는게 생각보다 까다로웠다

그냥 동일한 기능을 하는 코드를 새로 작성하는게 훨씬 나았다고 생각했는데 테오는 한번에 모든 코드를 변경하는게 아닌 점진적으로 리팩토링하는 감각을 기르는게 중요하다고한다

 

4주차 진행하면서 코딩은 정말 운동같은거다.. 라는걸 체감했다. 이전에도 클린코드에 대해서 문서나 블로그들을 많이 봤었는데

더티코드를 직접 클린코드로 변환하기 전까진 잘 모르는 내용이었다는걸 깨달았다.

이번주도 코딩근육이 조금 생긴거 같다. (아직 빈약한거 같다)

 

이번 주차는 raect의 다양한 hooks들을 만들고 사용해보며 동작원리를 체득해보는 시간이었다.

useRef, useMemo, useCallback, memo를 직접 구현해보면서 메모이제이션에 대해 더 깊게 생각해볼 수 있었고,

useContext로 랜더링을 최적화 하는 과정을 통해 보다 응집도 있는 코드 작성하는 법에 대해 배울수 있었다.

 

1. useRef

렌더링 사이에 값을 유지하는 ref 객체를 생성

export function useRef<T>(initialValue: T): { current: T } {
  const [ref] = useState({ current: initialValue });
  return ref;
}

 

처음엔 아래처럼 object에 값을 넣어도 동일하게 동작할 줄 알았다

const ref = { currnet: initialValue };

그런데 이렇게하면 렌더링이 될때마다 값이 초기화돼버렸다.

새로고침 하기 전까지는 값을 유지하는 useState 내부 저장소에 값을 저장했다

useState에 저장한 값을 변경하는 방법으로 리랜더링하지 않고 랜더링 사이에도 값을 유지하는 useRef를 구현했다. 

ref.current = "값 변경";

 

 


 

그런데 useRef를 구현하고보니 useState에 궁금증이 생겼다.

useState는 어디에 값을 저장하길래 object처럼 초기화되지 않는거지? 

 

React는 특정 공간에 state 값을 저장하고있다.

공식문서에서도 일반 변수는 랜더링하면 값 초기화되니까 렌더링에 사용할 변수는 useState를 사용하라고 강조하고 있다 (https://react.dev/learn/state-a-components-memory#)

 

클로저

React는 state의 값을 저장하기위해 클로저를 사용하고있다.

클로저를 통해 useState가 호출될 때마다 초기화되지 않고 기존 상태를 유지할 수 있게된다.

// 클로저를 통해 useState 구현

const createUseState = (initialValue = 0) => {
  let value = initialValue; // 클로저로 상태를 저장

  const state = () => value; // 상태를 반환하는 함수
  const setState = (newValue) => {
    value = newValue; // 상태를 업데이트
  };

  return [state, setState]; // 반환
};

const customUseState = createUseState(0); // 초기 상태를 0으로 설정
export default customUseState;
// 컴포넌트에서 사용

const [state, setState] = customUseState;
  const onClick = () => {
    setState(state() + 1);
    console.log(state());
  };

 

 

 

useState를 비슷하게 만들어보니 호출할 때마다 useState를 구분해줘야되는 문제점이 있었다.

리액트는 어떻게 해결했나 공식문서를 보니 역시 해답이 나와있었다.

  

useState는 각각의 useState 인덱스를 key 값으로 구분하고있다.

let componentHooks = [];
let currentHookIndex = 0;

function useState(initialState) {
  let pair = componentHooks[currentHookIndex];
  if (pair) {
	// 이미 호출된 useState 쌍이면 return 해서 호출 대기
	currentHookIndex++;
    return pair;
  }

  // 처음 호출된 useState 는 우리가 흔히 사용하는 [state, setState] 쌍을 만든다
  pair = [initialState, setState];

  function setState(nextState) {
    // setState가 변경되면 state 값을 변경하고 DOM을 update한다.
    pair[0] = nextState;
    updateDOM();
  }

  // 이후 렌더링을 위해 쌍을 저장하고 다른 useState 호출 대기를 위해 인덱스 +1
  componentHooks[currentHookIndex] = pair;
  currentHookIndex++;
  return pair;
}

 

2. useMemo

react는 useCallback, useMemo, memo 등의 의존성을 판단할 때 얕은 비교를 한다.

만약 깊은 비교를 통해 위 hook들을 사용하고 싶다면 별도의 커스텀 훅을 만들어 진행해야한다.

export function useMemo<T>(
  factory: () => T,
  _deps: DependencyList,
  _equals = shallowEquals,
): T {
  const prevDeps = useRef<null | DependencyList>(null);
  const prevFactory = useRef<null | T>(null);

  const isEqual = shallowEquals(prevDeps.current, _deps); // 얕은 비교
  if (!isEqual) {
    prevDeps.current = _deps;
    // 참조값 _deps 가 변경되면 factory함수를 다시 생성한다.
    prevFactory.current = factory();
  }

  return prevFactory.current as T;
}

 

3. useCallback

usememo는 값을 useCallback은 함수를 메모이제이션하므로 아래와 같이 간단하게 구현 가능하다. 

export function useCallback<T extends Function>(
  factory: T,
  _deps: DependencyList,
) {
  const _factory = useMemo(() => factory, _deps);

  return _factory as T;
}

 

4. memo

memo도 useMemo, useCallback과 동일하게 얕은비교를 하는데 react element를 다시 생성해줘야한다.

이때 넘겨준 props와 children을 return 하는 createElement라는 react 메서드를 사용한다.

export function memo<P extends object>(
  Component: ComponentType<P>,
  _equals = shallowEquals,
) {
  return function MemoizedComponent(props: P) {
    const prevProps = useRef<P | null>(null);
    const prevComponent = useRef<ReactElement | null>(null);

    const isEqual = shallowEquals(prevProps.current, props);
    if (!isEqual) {
      prevProps.current = props;
      prevComponent.current = createElement(Component, props); element 재생성
    }

    return prevComponent.current;
  };
}

 

항해 2주차가 뚝딱 지나갔다.

이번 주차에서는 항해가 커리큘럼을 정말 잘 짰다고 느꼈다. 매주 과제를 통과해야되는데 단계적으로 작성해놓은 테스트코드를 통과하는 과정이 자연스럽게 성장할 수 있는 발판이 된다.

 

1주차에선 자바스크립트로 라우팅과 이벤트 주입, 컴포넌트 기반 설계등을 구현해보면서 기본을 다지는 시간이었고,

2주차는 자바스크립트로 가상돔을 만들고 React처럼 동작하게 하면서 React의 내부 동작 원리와 이벤트 관리에 대해 더 깊게 이해해보는 시간이었다.

 

처음엔 가상돔 만드는 주제 보면서 '잉? 그걸 어떻게 만들지?' 라는 생각이 들었다

 

Hello, Virtual DOM

 

가상돔은 거창한게 아니라 DOM을 복사한 객체 덩어리다.

객체 덩어리 A, B를 만들어서 변경된 부분만 다시 렌더링해줘서 성능을 올리는 컨셉이다.

 

DOM에 변경 사항이 있을 때마다 매번 DOM을 새롭게 그리는게 아닌

가상돔으로 비교해 변경된 부분을 모아서 DOM을 새롭게 그린다.

실제론 DOM tree를 만들어 tree를 비교하게 된다

 

처음엔 그냥 DOM끼리 비교해도 되지 않나 싶었는데 Real DOM도 객체 덩어리인건 맞지만 덩어리 규모차이가 상당하다.

아래 코드처럼 간단한 div를 만들어 콘솔에 찍어보면 매우 많은 객체들이 있는것을 확인할 수 있다.

const $divElement = document.createElement('div'); 
console.log($divElement);


때문에 DOM 자체를 만들어 비교하려면 그만큼 비용이 많이 들고 DOM 자체가 브라우저 기능이어서 런타임 환경에서야 비교가 가능했다. (가상돔은 순수한 객체)  


그리고 가상돔의 다른 이점은 개발자 경험 향상에 있는데

자바스크립트의 명령형 코드로 UI를 만드는 것이 아닌 jsx를 통해 선언적으로 UI를 만들 수 있게 해준다. 

// 선언형
<button onClick={console.log("버튼입니다")}>버튼</button>
const $btn = document.createElement("button");
$btn.textContent = "버튼";
$btn.addEventListener("click", () => console.log("버튼입니다"));

 

 

추가로 가상돔이 항상 성능 향상에 기여하지는 않는다. 애플리케이션 규모나 기능에 따라 Real DOM을 조작하는 방식이 성능이 좋을 수 있으니 'React가 정답이야' 라는 것보다는 조금 객관적으로 트레이드오프를 고려해 볼 필요가 있다.

 


자바스크립트로 가상돔을 만들어 React처럼 동작하게 하는 과정은 크게 5가지 과정을 거친다.

 

1. 가상DOM 생성

RealDOM을 가상돔으로 변환해보자.

// Real DOM
<div id="parent">
  <span className="child">Child</span>
</div>

 

위의 Real DOM을 가상돔으로 구성하면 아래와 같이된다.

function createVDOM(type, props, ...children) {
	return { type, props, children.flat() }
}

createVDOM('div', { id: 'parent' },
	createVDOM('span', { className: 'child' }, 'Child');
);

 

그렇게 변환된 객체 덩어리는 아래와 같이된다.

// 가상돔
{
  type: "div",
  props: { id: "parent" },
  children: [
    {
      type: "span",
      props: { className: "child" },
      children: ["Child"],
    },
  ],
},

 

2. 가상DOM 정규화

 

정규화 함수를 통해 일관된 형식의 가상DOM을 반환하도록 한다


function normalizeVDOM(vDom) {
	// vDom이 null, undefined 또는 boolean 타입일 경우 빈 문자열을 반환합니다.
    if (vDom === null || vDom === undefined || typeof vDom === "boolean") {
        return "";
    }
    
    // vDom 문자열 또는 숫자일 경우 문자열로 변환하여 반환
    if (typeof vDom === "string" || typeof vDom === "number") {
        return String(vDom);
    
    
    // VDom이 함수일 경우 해당 함수를 호출해 반환된 결과를 재귀적으로 정규화 
    if (type vDom.type === "function") {
    	const props = { ...vDom.props, children: vDom:children  }
        return normalizeVDOM(vDom.type(props))
    }
    
    // 그 외의 경우 vDom의 자식 요소들을 재귀적으로 정규화
    const normalizedChildren = vDom.children.map((child) => normalizeVDOM(child))
    
    return { ...vDom, children: normalizedChildren }
}

 

아래 컴포넌트화 시킨 함수를 정규화해보자

  const TestComponent = () => (
    <ul>
      <li id="item-1">Item 1</li>
      <li id="item-2">Item 2</li>
      <li id="item-3" className="last-item">Item 3</li>
    </ul>
  );
  
  const normalized = normalizeVNode(<TestComponent />);

 

이런 결과가 나온다

{
  type: "ul",
  props: {
  },
  children: [
    {
      type: "li",
      props: {
        id: "item-1",
        className: "list-item ",
      },
      children: [
        "- ",
        "Item 1",
      ],
    },
    {
      type: "li",
      props: {
        id: "item-2",
        className: "list-item ",
      },
      children: [
        "- ",
        "Item 2",
      ],
    },
    {
      type: "li",
      props: {
        id: "item-3",
        className: "list-item last-item",
      },
      children: [
        "- ",
        "Item 3",
      ],
    },
  ],
}

 

3. 가상DOM을 Real DOM으로 변환

function createDOM(vDom) {
	// vDom이 null, undefined, boolean일 경우, 빈 텍스트 노드 생성하여 반환
    // vDom이 문자열이나 숫자면 텍스트 노드 생성하여 반환
    // vDom이 배열이면 DocumentFragment 생성 후 각 자식에 대해 createDOM 재귀 호출하여 추가
    
    // 그 외의 경우 (vDom이 컴포넌트인 경우) DOM 생성
    const { type, props, children } = vDom;
    const $el = document.createElement(type);
    // $el에 props 주입
    updateAttributes($el, props);
    
    // children 있는 경우 (부모와 동일하게 DOM생성, props 주입)
    children.forEach((child) => {
        const $child = createElement(child);
        updateAttributes($child, child?.props);
        $el.appendChild($child);
	});
    
    return $el;
}

// DOM에 props 주입
function updateAttributes($el, props) {
	Object.entries(props).forEach(([key, value] => {
    	// 이벤트인 경우 jsx -> js 로 변경 (ex. onClick -> click)
		if (key.startsWith("on") && typeof value === "function") {
			const event = key.toLowerCase().slice(2);
            // 이벤트는 별도로 저장해놨다가 이벤트 위임방식으로 등록
            addEvent($el, event, value);
		} else {
			$el.setAttribute();
		}
	});
}

 

4. 이벤트 주입

createDOM 함수가 실행될 때 이벤트인 경우에 별도로 저장해놨다가 DOM이 생성된 후에  저장해둔 이벤트를

부모 DOM에 등록한다 (이벤트 위임 방식으로 주입)

 

const eventStore = new Map();

function addEvent(element, eventType, handler) {
  let depthMap = eventStore.get(eventType);
  if (!depthMap) {
    depthMap = new Map();
    eventStore.set(eventType, depthMap);
  }
  depthMap.set(element, handler);
}
function setupEventListeners(root) {
  if (eventStore.size < 1) return;

  Array.from(eventStore.entries()).forEach(([eventType]) => {
    const depthMap = eventStore.get(eventType);
    Array.from(depthMap.entries()).forEach(([element, handler]) => {
      if (rootStore.get(element)?.has(handler)) {
        return;
      }
      const eventHandler = (e) => {
        if (element === e.target) {
          handler(e);
        }
      };
      root.addEventListener(eventType, eventHandler);
    });
  });
}

 

5. 렌더링

그동안 작성한 코드를 이용해 SPA 처럼 렌더링시켜준다.

function renderDOM(vDom, container) {.
  const normalizedvDom = normalizeVNode(vDom);
  const $el = createElement(normalizedvDom);
  
  // 최초 렌더링시에는 createElement로 DOM을 생성
  if (!container.__oldVDom) {
    container.appendChild($el); // 최초 렌더링 시 DOM 생성
    
  // 이후에는 변경된 부분만 기존 DOM을 업데이트한다.
  } else {
    container.replaceChild($el); // 대충 퉁쳤는데 변경된 가상돔을 check해서 update하는 부분 필요
  }

  container.__oldVDom = normalizedVDom;

  // 렌더링이 완료되면 container에 이벤트를 등록한다
  setupEventListeners(container);
}

 

 

 

FE 개발자로 일한지 2년정도 되는 요즈음 문득 이런 생각이 들었다

"나 잘하고 있는거 맞나?"

 

일은 하고 있는데 동료 개발자도 거의 없다시피 작은회사에서 쌓은 시간이라 객관적으로 나를 측정하고 싶었다

그렇게 항해플러스를 시작했다

 

2주차가 끝나는 지금 항해플러스에 합류한건 참 잘한 선택이었다. 자바스크립트의 본질에 대해 이해하고 배우는 좋은 시간이고 비슷한 연차에서의 나의 위치를 정확히 알 수 있었다. 비슷한 고민을 하는 개발자들 사이에서 과제 뿐 아니라 생각을 교류하는 좋은 환경과 커리큘럼이라고 생각한다. 이런 것들을 나누면서 자연스레 좋은 개발자, 함께 일하고 싶은 개발자가 뭔지 알듯하다.

 

갓 1년차가 되었을 때부터 항해플러스를 신청할까 말까 고민을 많이했다. 그러다 끝내 신청하지 못했던건 가격적인 측면도 있었지만 '코어 자바스크립트에 대한 것, 아키텍쳐도, TDD도 지금 사용을 안하는데 궂이 해야되나?' 싶었다  

그것도 지금은 잘못 생각했다고 느끼는게 코어 자바스크립트도 아키텍쳐도 내가 모르니까 사용을 안했구나 싶다.

물론, React 환경에서 지금 배우고있는 DOM 조작과 class문법 사용 등은 지양되고 있지만, React 라는 프레임워크 기저에는 이런 것들이 다 녹아져있었고 알면 기술이지만 모르면 마법이라는 생활코딩님의 말이 종종 생각나는 시간을 보내고있다.

 

그래서 요번주에 뭘했냐면 

 

 

자바스크립트로 React만들기

 

자바스크립트로 React처럼 화면전환이 가능하게 route를 구현하고 가상돔과 diff 알고리즘을 만들어 변경되는 node만 업데이트해주는게 기본 골자다.

 

위의 기능을 위해서 아래의 구현이 필요하다

 

1. 라우팅

자바스크립트에서 기본으로 제공하는 history api를 이용해 페이지를 새로고침 하지 않고 URL 을 변경한다.

 

여러 메서드들이 있는데 하나씩 알아보자.

  • pushState

기본 골자는 이렇다. history.pushState(state, title, url);

State : 브라우저 이동  넘겨줄 데이터 (popstate 에서 받아서 원하는 처리를 해줄  있음)

Title : 변경할 브라우저 제목 (변경 원치 않으면 null)

Url : 변경할 주소

 

아래 예시를 보자.

// html
<nav>
  <div id="navi">/</div>
  <div id="navi">/about</a>
</nav>
document.querySelector("nav").addEventListener("click", (e) => {
  if (e.target.id === "navi") {
    history.pushState(null, "", e.target.innerText);
  }
});

 

위와 같이 사용하면 새로고침 없이 "/" 와 "/about" url로 변경할 수 있다.

 

이때 각 링크에 따라 html만 변경해주면 SPA처럼 동작한다.

아래 예시를 보자

// main.js

class Router {
  constructor() {
    this.routes = {};
  }

  addRoute(path, handler) {
    this.routes[path] = handler;
  }

  navigateTo(path) {
    history.pushState(null, "", path);
    this.handleRoute(path);
  }


  handleRoute(path) {
    const handler = this.routes[path];
    if (handler) {
      handler();
    } else {
      console.log("404 Not Found");
    }
  }
}

// router 등록
const router = new Router();
router.addRoute("/", () => renderPage(mainPage()));
router.addRoute("/about", () => renderPage(aboutPage()));

// 페이지 이동 시 router.navigateTo() 호출
document.querySelector("nav").addEventListener("click", (e) => {
  if (e.target.id === "navi") {
    router.navigateTo(e.target.innerText);
  }
});

// URL에 따라 html을 갈아준다
function renderPage(page) {
  document.getElementById("app").innerHTML = page;
}

function mainPage() {
  return `
    <h1>메인 페이지입니다</h1>
`;
}

function aboutPage() {
  return `
    <h1>About 페이지입니다</h1>
`;
}

 

요약하면 아래 순서로 진행된다.

1. addRoute 함수로 사용할 route를 등록하고 ("/", "/about")
2. navigateTo함수로 새로고침 없이 url을 변경하고 (history.pushState)

3. 2를통해 url 변경이 일어나면 등록한 route의 콜백함수를 실행해준다

4. 이때 콜백함수는 html을 render 해주는 함수

 

 

여기서 아래 코드를 추가하면 뒤로가기 앞으로가기를 통해 변경되는 url을 대응할 수 있다

class Router {
  constructor() {
    this.routes = {};
    
    // (New!)
    window.addEventListener("popstate", this.handlePopState.bind(this));
  }

  addRoute(path, handler) {
    this.routes[path] = handler;
  }

  navigateTo(path) {
    history.pushState(null, "", path);
    this.handleRoute(path);
  }

  // (New!)
  handlePopState() {
    this.handleRoute(window.location.pathname);
  }

  handleRoute(path) {
    const handler = this.routes[path];
    if (handler) {
      handler();
    } else {
      console.log("404 Not Found");
    }
  }
}

 

위의 과정을 통해 SPA 처럼 페이지 이동이되게 했는데 이벤트는 어떻게 관리하지?

 

2. 이벤트 주입 (아키텍쳐를 곁들인)

element가 존재해야(이하 $el) $el에 이벤트를 주입할 수 있다.

그러면 html을 먼저 파싱하고 그 후에 이벤트를 주입해야 되는건데 요 과정에서 아키텍쳐에 대한 중요성을 배웠다

그동안은 react로만 구현해서 몰랐는데 자바스크립트로 구현하자니 코드가 많아지고 관리하기가 힘들었다

(선언형과 명령형의 차이 +exp) 

 

...컴포넌트 기반 구조 설계를 위한 아키텍쳐 구성은 곧 추가하겠습니다!🙌

 

 

 

 

 

앗.. 아아...

 

프로젝트 중 Ai 기능이 있어 외부와 연계해서 작업을 해야했다.

서버pc에 있는 이미지에 접근해 해당 이미지를 추론하고 추론 이미지 생성 & 추론 data를 DB에 담아주면

나는 이미지랑 data를 가져다 쓰는 방식으로 진행했다.

  

난 파일에 접근 가능하니까 업로드도 가능한줄 알았다 (웨 그렇게 생각하셨죠?? -> 나도 모름, 진짜 모름)

기존에 사용하는 이미지가 많아 서버 PC에 이미지를 저장하고 외부에서 접근 가능하게 해놓은 상태여서

(port & 포트포워딩)

 

외부 인력와서 추론 알고리즘 pc에 설치해줄때도 파일 시스템에 접근이 되는 거니까 업로드도 가능한건가 보다~ 하고 할일 하고 있었다 

 

아래는 예시 백엔드 코드

const express = require('express');
const path = require('path');
const cors = require('cors');

const app = express();
const PORT = 3000;

// CORS 설정: 모든 도메인 허용, GET과 POST 메서드만 허용
app.use(cors({
    methods: ['GET', 'POST']
}));

app.use(express.json());

// 'public/image' 폴더 경로 설정
const folderPath = path.join(__dirname, 'public', 'image');

// 정적 파일 제공
app.use('/images', express.static(folderPath));

// 서버 시작
app.listen(PORT, () => {
    console.log(`이미지 server 실행`);
});

 

근데 작업하시다가 음 405 에러 나는데 왜그러징.. 하는걸 한참 뒤에 들었다 서로 다른 공간에서 작업하니까 작업하는 분은 본인이 잘못해서 그런갑다 하고 이것저것 해보고있었는데 공유한 url로 파일 시스템에 접근해서 이미지 다운로드는 되는데 업로드가 안되니까 본인이 잘못 한건줄 알고 계속 시도해보고 있었던것.. (안타깝게도 외부 인력도 나도 주니어여서 귀책사유를 명확히 알지 못했음)

 

결론부터 말하면 왜 안됐냐면...

 

그야.....

 

업로드 api가 없으니 업로드가 안되지!!

 

정말 부끄러운 이유로 에러가 나고있었고 "405 에러 나는데용?" 라는 말을 듣고 GPT 답변을 이것저것 읽어보고 생각했다.

(와 나 뭐함..?)  

 

호다닥 GPT 도움을 받아 코드를 수정했고 다행히 바로 업로드가 됐다

예시 코드

const multer = require('multer');

// multer 설정: 파일 저장 경로 및 파일 이름 설정
const storage = multer.diskStorage({
    destination: (req, file, cb) => {
        cb(null, YOUR/PATH); // YOUR/PATH 에 업로드 할 경로 입력
    },
    filename: (req, file, cb) => {
        cb(null, file.originalname); // 원래 파일 이름으로 저장
    }
});

const upload = multer({ storage: storage });

// 파일 업로드 api를 만들어줘야 업로드가 되는 지극히 당연한 사실!!
app.post('/api/upload', upload.single('file'), (req, res) => {
    if (!req.file) {
        return res.status(400).json({ message: '파일이 업로드되지 않았습니다.' });
    }
    res.json({
        message: '파일이 성공적으로 업로드되었습니다.',
        file: req.file
    });
});

 

multer 분명 공부할때 썼었는데..

 

경험해보지 않으면 실전에선 절대 생각이 안나는듯..?

*multer는 주로 파일 업로드에 사용하고 미들웨어로 에러 같은거 나면 next에 처리를 위임해줘서 사용하기 좋다

 

회사에서 진행하는 프로젝트가 1인 프로젝트 치고 규모도 좀 있고 경력도 거의 프론트만 1년 정도 한게 다여서 나름 부담감에  MongoDB 강의도 듣고 React deep dive 도 하고 코테도 깔짝깔짝 풀고 있었는데 이번 일 겪은 후로 다 치우고 백엔드 위주로 사이드 프로젝트 하면서 공부 중이다..

 

이것저것 하고있긴한데 교류하고있는 개발자 커뮤니티가 없으니 가끔 헤매고 있는거 같아 좀 불안허네

 

뭐 다들 화이팅합시다~🙌

'Node.js' 카테고리의 다른 글

백엔드 시작하기  (4) 2024.10.26
음.. 프론트엔드만 할 줄 알았는데

 

다른 개발자들 상황을 잘 아는건 아니지만,

회사성향에 따라 그리고 규모에따라 결국 풀스텍을 하게되는 경우가 왕왕 있는듯 하다

뭐 나도 그렇다

 

첫 회사에선 일렉트론으로 데스크탑 애플리케이션 만들다보니 node.js도 어찌어찌 작성했었는데

db 안쓰는 프로젝트고 api 통신도 아닌것이 뭔가 애매해서 백엔드 코드는 대충대충 했던 기억이 난다.

 

근데 지금회사에선 좀 본격적으로 백엔드 개발도 해야돼서 복습겸 회고하려고 한다.

프론트엔드 역량에 더 집중하고 싶기도한데 지금 회사에 필요한 건 이것저것 할 줄 아는 개발자인듯 하고 이렇게 역량 늘리는 것도 나쁘지 않긴하다.

기왕하는거 즐깁시다.

 

 

내가 생각하는 백엔드

프론트에서 필요한게 있으면 서버로 요청하고 서버는 응답한다.

백엔드는 응답하는 로직을 담당하는 부분이라고 생각한다

간단한 비즈니스 로직부터 db 조작까지해주는 정도?

 

 

근데 비즈니스 로직도 프론트엔드에서 private 하게 다룰수 있고 (feat. class 문법)

Next.js 13부터 서버 컴포넌트에서 바로 db CRUD가 가능해져서 프론트와 백엔드 경계가 모호하다고 느끼고있긴하다.

 

그래도 기본은 중요한거니까 기초를 다져봅시다.

 

어떻게 공부할까 하다가 전에 사둔 [리액트를 다루는 기술] 책에 백엔드 프로그래밍 부분이 있어서 진도를 따라가고 있다.

책에 수록된 스택은 Koa(기존 Express 팀이 개발한 더욱 가벼운 프레임워크) + Javascript + mongoose 인데,

 

Express + Typescript + mongoose 로 공부하고 싶어서 해당 스택으로 진행했다.

 

 

환경설정

 

1) express 설치

$ npm init
$ npm install express --save

 

2) Typescript 관련 설치

npm i -D typescript     // typescript 설치 (-D 는 개발모드로)
npm i ts-node		// 아래 후술
npm i @types/node	 
npm i @types/express

 

<ts-node> 
- TypeScript를 JavaScript로 변환하여 사전 컴파일 없이 Node.js에서 TypeScript를 직접 실행할 수 있도록 합니다(문서 발췌) 이거 없으면 ts 파일 하나당 컴파일 된js 파일 하나 만들어지고 디버깅도 컴파일된 js 파일에서 해야돼서 매우 피곤함

 

<types/node> 

- 이 패키지에는 노드에 대한 유형 정의가 포함되어 있습니다. (문서 발췌)

없으면 ts 파일에서 Node.js의 내장 모듈(예: fs, http, path 등)을 사용할 때 Type 에러남.

 

<types/express>

 

- 이 패키지에는 express  에 대한 유형 정의가 포함되어 있습니다. (문서 발췌)

없으면 ts 파일에서 express 사용할 때 Type 에러남

 

3) tsconfig.json 파일을 만들어 준다

npx tsc --init

 

4) nodemon 설치

npm i -D nodemon //  ts파일이 변환되었을때 알아서 서버를 재시작해주는 유틸 라이브러리

 

5) npm 명령어로 nodemon 실행

// package.json

"scripts": {
    "start": "nodemon server.ts"
},

 

 

 

Express

 

express 는 백엔드를 쉽게 즐기게 해주는 프레임워크.

Http 메서드 및 미들웨어를 통해 API 작성에 도움을 준다 

1) express 시작하기
 
// 코드 몇줄 딸깍으로 서버를 시작할 수 있다

const express = require('express')
const app = express()
const port = 3000

app.get('/', (req, res) => {
  res.send('Hello World!')
})

app.listen(port, () => {
  console.log(`Example app listening on port ${port}`)
})

 

2) 기본 라우팅

// metods를 구분해 하나의 route에 4개의 요청과 응답이 가능하다
// postman을 사용해 작성한 api를 test 해보자

app.get('/', function (req, res) {
  res.send('Hello World!');
});

app.post('/', function (req, res) {
  res.send('Got a POST request');
});

app.put('/', function (req, res) {
  res.send('Got a PUT request');
});

app.delete('/', function (req, res) {
  res.send('Got a DELETE request');
});

 

 

 

mongoDB

 

몽고DB는 크로스 플랫폼 도큐먼트 지향 데이터베이스 시스템이다. NoSQL 데이터베이스로 분류되는 몽고DB는 JSON과 같은 동적 스키마형 도큐먼트들을 선호함에 따라 전통적인 테이블 기반 관계형 데이터베이스 구조의 사용을 삼간다

(나무 위키 발췌)

 

음.. 그냥 처음 db 써보기 좋은 친구.

처음 용어도 좀 헷갈렸는데 몽고DB에서 Document는 JSON 객체라고 보면 될듯하다

const dummy = [
  {name: "철수"},
  {name: "영희"},
];

위 dummy 배열을 몽고DB에 넣는다고 하면

1번 Document에는 {name: "철수"} 가
2번 Document에는 {name: "영희"} 가 들어가는 느낌?

(Document마다 _id 를 고유값으로 가진다.)

 

SQL 진형의 DB랑 비교해보면 스키마가 유동적이라 매우 쓰기 편하다.

RDBMS와 DB 설계 차이를 비교해보자

 

SQL 진형에서 포스팅 / 댓글 기능을 구현한다고 하면

user / post / comment 테이블을 설계한다. 

출처: https://kkt9102.github.io/db/MongoDB%EB%9E%80/

 

하지만 NoSQL 에서는 하나의 Document에 쑤셔넣는다.

{
  _id: ObjectId,
  title: String,
  body: String,
  user_id: String,
  reg_dt: Date,
  commments: [
      {
          _id: ObjectId,
          text: String,
          reg_dt: Date
      },
  ],
}

 

백엔드 개발을 시작했을때 이 DB구조에서 애먹었다.

적당히 설계했다가 필요한 데이터를 찾기위해 비슷한 컬렉션을 추가하는 등의 일이 왕왕 발생했다

(뭐 지금도 크게 다르진 않다..)

 

 

mongoose

 

mongoDB 확장판 정도로 데이터 모델링, 유효성 검사, 미들웨어 등 다양한 기능을 통해 개발자의 생산성을 높여준다

스키마가 고정되어있지 않은 mongoDB를 그냥 쓰면 같은 컬렉션의 Document 마다 data 형식이 다르게 들어가  점점 관리하기 어려워진다. (미들웨어 같은 기능은 express와 유사해 같이 공부하면서 자동으로 복습도 되고 좋았다.)

 

 

1) mongoose 시작하기

import mongoose from "mongoose";
import "dotenv/config";

const { MONGO_URI } = process.env;

async function connectDB() {
  if (!MONGO_URI) throw new Error("환경변수 설정 필수!");

  try {
    await mongoose
      .connect(MONGO_URI)
      .then(() => {
        console.log("mongodb connect!");
      })
      .catch((err) => console.error(err));
  } catch (err) {
    console.error(err);
  }
}

export { connectDB };

 

2) 스키마(schema)를 만들기

스키마는 Document에 들어가는 데이터가 어떤 형식으로 되어 있는지 정의하는 객체.

모델은 스키마를 사용해 만드는 인스턴스로, 데이터베이스에서 실제 작업을 처리할 수 있는 함수들을 가진 객체.

 

모델을 사용하려면 아래와 같이 사전에 스키마를 만들어주어야됨.

import mongoose from "mongoose";
const { Schema } = mongoose;

// post 
const PostSchema = new Schema({
  title: String,
  body: String,
});

const Post = mongoose.model("Post", PostSchema);
export default Post; // 이 Post 모델 객체를 사용해 db 조작이 이루어진다

 

 

3) 모델(model) 사용

import { Post } from "./models/post"; // 만들어준 Post 사용

app.post("/post", async (req, res) => {
  const { title, body } = req.body;
  const post = new Post({ 	// 인스턴스를 만들어 사용
    title: title,
    body: body,
  });
  try {
    await post.save();
    res.send("db 저장 성공!", JSON.stringify(post));
  } catch (err) {
    console.error(err);
    res.status(500).send("db 저장 실패!");
  }
});

 

 

 

Typescript

 

express도 mongoose 도 공식문서가 친절해서 참 좋다.

그중 어려웠던 mongoose 의 인스턴스 메서드와 스태틱 메서드에 대해서 작성하겠다

 

1) 스키마 생성

const userSchema = new Schema({
  userName: String,
  hashedPassword: String,
});

const User = mongoose.model("User", userSchema);
export default User;

 

2) 인스턴스 메서드

// 인스턴스 메서드 정의
userSchema.methods.hello = function () {
  return 'hello!!';
};

// 인스턴스를 만들어 사용
const user = new User({ userName: 'jack' });
console.log(user.hello()); // "hello!!"

 

3) 스태틱 메서드

// 스태틱 메서드 정의
userSchema.statics.findByUserName = function (userName: string) {
  return this.findOne({ userName });
};

// User 모델에 바로 접근해서 사용
User.findByUserName("jack").then(name => {
  console.log(name); // "jack", "jack" 이름을 가진 user 출력
});

 

4) Type 추가 (공식 문서 링크)

interface IUser {
  userName: string;
  password?: string;
  hashedPassword?: string;
}

// 인스턴스 메서드 type 정의
interface IUserMethods {
  hello(): string;
}

// static 메서드 type 정의
interface UserModel extends Model<IUser, {}, IUserMethods> {
  findByUserName(
    userName: string
  ): Promise<HydratedDocument<IUser, IUserMethods>>;
}

// user 스키마 type 정의
const userSchema = new Schema<IUser, UserModel, IUserMethods>({
  userName: { type: String, required: true },
  password: { type: String },
  hashedPassword: { type: String },
});

userSchema.methods.hello = function () {
  return 'hello!!';
};

userSchema.statics.findByUserName = function (userName: string) {
  return this.findOne({ userName });
};

const User = model<IUser, UserModel>("User", userSchema);

export { User };

 

 

끝! 읽어주셔서 감사합니다~

 

'Node.js' 카테고리의 다른 글

서버pc에 파일 업로드  (3) 2024.11.09

 

serverless 프레임워크를 통해 lambda를 배포하자

 

serverless 설치

npm i serverless -g

 

명령어로 시작

serverless

 

docs 에는 starter 로 시작하라고 하는데 최근 업데이트를 해서 선택지가 없다. (docs는 업데이트가 안됐다..)

Simple Function을 선택하자

 

AWS 자격 증명 설정

여기 중요하다. 처음에 Easy & Recommended로 만들었는데 한번 만들면 serverless 에 등록이 되어버려 삭제하고 다시 Iam을 등록해줘야된다. 최근 업데이트 된거라 docs에도 삭제관련 내용이 없어서 한참 해맸다.. 우린 이미 만들어준 iam 을 연결해야되니 Easy & Recommended 로 만들어주는 iam은 필요가없다.

2번째 Save AWS Credentials을 선택해준다.

만약 1번을 선택했다면 다음 과정을 따라해보자.

serverless login 후에 나오는 serverless app 에서 등록되어버린 iam을 직접 삭제해준다.

저 provider 부분은 lambda 배포할때 필요한거긴 한데 저게있으면 serverless 가 AWS 자격 증명 설정 선택지를 안준다..

그래서 저거 삭제하고 2번으로 선택한 후 저 provider iam을 만들어서 다시 넣어줬다

(업데이트 된지 얼마 안되서인지 docs에 관련 내용이 없어서 이부분은 계속 오류나면서 이것저것 해보다가 된거라 맞는 방법인지 확신은 없다)

 

플러그인 설치

npm install --save-dev serverless-lambda-edge-pre-existing-cloudfront

이미 있는 cloudfront에 lambda를 배포하려면 추가 작업이 필요하다. (관련 docs)

docs에 나온대로 yml 파일도 작성해주고

// serverless.yml

functions:
  hello:
    handler: index.hello // index 파일의 hello 함수 배포
    events:
      - preExistingCloudFront:
          # ---- Mandatory Properties -----
          distributionId: ID # CloudFront distribution ID you want to associate
          eventType: origin-response # Choose event to trigger your Lambda function, which are `viewer-request`, `origin-request`, `origin-response` or `viewer-response`
          pathPattern: "*" # Specifying the CloudFront behavior
          includeBody: false # Whether including body or not within request
          # ---- Optional Property -----
          # stage: dev # Specify the stage at which you want this CloudFront distribution to be updated
plugins:
  - serverless-lambda-edge-pre-existing-cloudfront

 

추가로 1탄에서 만든 iam 을 입력해준다

// serverless.yml

provider:
  name: aws
  runtime: nodejs20.x
  iam:
    role:
      arn:aws:iam::[YOUR IAM ARN]

 

 

이제 적당한 예시 함수를 배포해보자

// index.js

export const hello = async (event) => {
  console.log(JSON.stringify(event, null, 2));
  return event;
};

 

배포 명령어 입력

serverless depoly

 

배포가 성공하면 버지니아 북부 > Lambda > 함수 에서 확인할 수 있다. 

 

최신 버전으로 들어가면

 

트리거 Cloudfront가 잘 연결되어있다 

 

테스트 코드도 잘 들어가있고

 

잘 되는지 테스트도 할수있다.

querystring 으로 선택

 

아래 로그는 cloudwatch 로 연결되는데 로그를 더 정확하게 보고싶을때 사용한다.

(무수한 에러의 요청에 많이 보게된다..)

 

test 함수가 잘 배포되었으니 실제 리사이징 코드를 작성하자.

"use strict";
import Sharp from "sharp";
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";

// S3 클라이언트 초기화
const S3 = new S3Client({
  region: "ap-northeast-2",
});

// 쿼리스트링에서 특정 키의 값을 가져오는 함수
const getQuerystring = (querystring, key) => {
  return new URLSearchParams("?" + querystring).get(key);
};

// 이미지 리사이즈 함수
export const imageResize2 = async (event, context) => {
  console.log("imageResize 실행!!");

  // 이벤트에서 요청 데이터 가져오기
  const { request, response } = event.Records[0].cf;

  // 쿼리스트링 가져오기
  const querystring = request.querystring;
  
  // 쿼리스트링이 없으면 원본 이미지 반환
  if (!querystring) {
    console.log("querystring is empty!! return origin");
    return response;
  }

  // URI 디코딩
  const uri = decodeURIComponent(request.uri);

  // 파일 확장자 추출
  const extension = uri.match(/(.*)\.(.*)/)[2].toLowerCase();
  console.log("extension", extension);

  // GIF 파일은 리사이징하지 않고 원본 반환
  if (extension === "gif") {
    console.log("extension is gif!! return origin");
    return response;
  }

  // 쿼리스트링 파싱
  const width = Number(getQuerystring(querystring, "w")) || null;
  const height = Number(getQuerystring(querystring, "h")) || null;
  const fit = getQuerystring(querystring, "f");
  const quality = Number(getQuerystring(querystring, "q")) || null;
  console.log({ width, height, fit, quality });

  // S3 버킷 이름 및 경로 추출
  const s3BucketDomainName = request.origin.s3.domainName;
  let s3BucketName = s3BucketDomainName.replace(
    ".s3.ap-northeast-2.amazonaws.com",
    ""
  ).replace(".s3.amazonaws.com", "");
  console.log("s3BucketName", s3BucketName);
  
  const s3Path = uri.substring(1);
  console.log("s3Path: ", s3Path);

  // S3에서 이미지 가져오기
  let s3Object = null;
  try {
    s3Object = await S3.send(
      new GetObjectCommand({
        Bucket: s3BucketName,
        Key: s3Path,
      })
    );
    console.log("S3 GetObject Success");
  } catch (err) {
    console.log("S3 GetObject Fail!!", err);
    return err;
  }

  // 이미지 리사이즈 수행
  const s3Uint8ArrayData = await s3Object.Body.transformToByteArray();

  let resizedImage = null;
  try {
    resizedImage = await Sharp(s3Uint8ArrayData)
      .resize({ width, height, fit })
      .toFormat(extension, { quality })
      .toBuffer();
    console.log("Sharp Resize Success");
  } catch (err) {
    console.log("Sharp Resize Fail!!", err);
    return err;
  }

  // 리사이징한 이미지 크기 확인
  const resizedImageByteLength = Buffer.byteLength(resizedImage, "base64");
  console.log("resizedImageByteLength:", resizedImageByteLength);

  // 리사이징한 이미지가 1MB 이상일 경우 원본 반환
  if (resizedImageByteLength >= 1048576) {
    console.log("resizedImageByteLength >= 1048576!! return origin");
    return response;
  }

  // 리사이징한 이미지 응답 설정
  response.status = 200;
  response.body = resizedImage.toString("base64");
  response.bodyEncoding = "base64";
  console.log("imageResize 종료!!");
  return response;
};

(코드 출처)

 

실제 쿼리스트링에 따라 lambda 함수가 실행되는지 확인해보자

Miss from cloudfront 인 경우( cloudfront 캐싱 전일 때)

 

위의 lambda에 배포한 리사이징 함수가 실행된다.

 

새로고침 후 Hit fron cloudfront ( cloudfront 캐싱됐을때 )

 

lambda 함수가 실행되지 않는다

 

s3, cloudfront, lambda 등등 aws 인프라 이용하면서 DevOps에 흥미가 생겼고 단순히 기능을 구현하는 건 정말 개발의 일부구나 생각했다. (그리고 aws 주식을 사고 싶어졌다.)

다음은 요새 뜨고있는 cloudflare 인프라도 이용해보고 싶은데 도메인 연결해서 cloudflare 이미지 리사이징을 해보는것도 재밌겠다.

 

출처 및 참고했던 블로그

aws docs

serverless docs

 

그대로 따라하기 정말 좋았던 연우리님 블로그

inpa 블로그

inpa 블로그-2 (개념)

올리브영 테크블로그

'aws' 카테고리의 다른 글

AWS cloudfront & lambda@edge 로 이미지 리사이징 -1  (0) 2024.08.22

+ Recent posts