우아한테크코스

[우아한 테크 코스 7기] 프리코스 4주 차 회고

뎁희 2024. 11. 19. 22:22

프리코스는 11월 11일에 종료되었지만 4주 차 회고가 늦었다. 부담 갖지 말자고 되새겼지만 나도 모르는 새에 한 달 동안 너무 집중을 많이 했던 것 같다. 겨우 정신 차리고 너무 어렵게 느껴졌던 4주 차 미션을 다시 구현해 보았다. 이번 글은 미션 과정에서 마주쳤던 벽 위주로 작성해보려고 한다.


파일 입/출력

public 폴더의 md 파일을 보고 멈칫했다. 첫 번째로 보통 public에서 mock 데이터를 사용할 때는 json 형식이 많았었고, 두 번째로 불러올 때는 fetch를 이용해서 절대경로를 사용했었는데 둘 다 해당되지 않는 케이스였기 때문이다. 

 

이번 경우에는 Node.js의 파일 입/출력 방법을 사용해야 했다. `fs`와 `path`를 활용하면 되는데 처음 해보는 것이라 조금 복잡하게 돌아 제출용 코드에서는 유틸 함수까지 만들어가며 적용했다. 

 

우선 입/출력 방법에는 동기/비동기로 구분하여 2가지가 있다.

1. 동기

const filePath = path.join(process.cwd(), "public/products.md");
const data = fs.readFileSync(filePath, "utf-8");

`process.cwd()`은 현재 작업 디렉토리를 반환한다. 즉, 작업 중인 폴더 경로를 나타낸다. 그럼 최종 filePath는 `폴더경로/public/products.md`가 된다. `readFileSync`은 파일을 동기적으로 읽는다.  

2. 비동기

const filePath = path.resolve(__dirname, `../public/${filename}.md`);
const data = await fs.promises.readFile(filePath, 'utf-8');

`__dirname`은 현재 스크립트 파일이 위치한 디렉터리를 반환한다. path.resolve는 __dirname을 기준으로 2번째 인자인 경로를 잡는다. 정말 낯설어 보이는 `fs.promises.readFile`는 파일을 비동기적으로 읽는다. 

 

우선 실제로 제출한 코드에서는 2번 방식을 사용했다. 그 이유는 처음 앱을 실행하면서 products와 promotions 파일을 동시에 파싱 해서 저장하고 싶었다. 비동기 처리를 해서 `Promise.all`을 하면 되겠다는 생각으로 접근했다.

 

static async loadData() {
  const [products, promotions] = await Promise.all([Parser.read(STORE.PRODUCTS), Parser.read(STORE.PROMOTIONS)]);
  this.products = products.map((data) => new Product(data));
  this.promotions = promotions.map((data) => new Promotion(data));
}

여기에서 복잡해진 부분은 `process.cwd()`의 존재를 몰라서 commonJS 모듈의 __dirname을 사용하기 위해 고민하다가 요상한 방법을 적용했다. 

 

const fs = require('fs');
const path = require('path');

module.exports = { fs, path, __dirname };

최종 코드에서는 이렇게 내보낸 __dirname을 import 하여 사용했다. ESM에서는 더 이상 __dirname을 사용하지 않는다. 대체할 수 있는 방법들이 분명 그 당시에는 안 됐었는데 다시 구현하며 보니 경로 설정을 잘못했다.

 

import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const filePath = path.join(__dirname, `../../public/products.md`);

`fileURLToPath`와 `path.dirname`을 이용해서 commonJS와 유사하게 파일을 읽을 수 있다. 하지만 간단히 `process.cwd()`를 사용하면 될 것 같다.

 

fetch와 정적 파일 처리

이전에 Node에 내장된 fetch를 이용해 public 폴더의 데이터를 읽어왔던 기억이 나서 시도했지만 예상한 대로 동작하지 않았다. 그 이유는 fetch로 로컬의 public 폴더를 정적으로 처리하기 로컬 서버가 필요하기 때문이었다. Node.js 자체는 정적 파일 서빙 기능이 없다고 한다. 이전 프로젝트들을 CRA나 Vite와 같은 빌드툴을 사용했기 때문에 제공되는 개발 서버 덕분에 public 폴더 내 파일을 `/`로 경로로 접근할 수 있다. 미션 폴더는 Node를 사용하는 것 외에 별도 개발 서버가 존재하지 않으므로 로컬 파일을 직접 읽어와야 한다.


상품과 프로모션 데이터

양식을 지키면 md 파일을 수정해도 된다는 안내문을 덥석 물어 테스트 코드를 통과하기 위해 직접 파일을 건드려버렸다. 어떤 수정을 해도 제출 시 4개 중 1개의 테스트를 통과하지 못했는데 그 원인이 이거였을 것이라 예상한다. 결국 해야 할 작업은 읽어온 파일을 원하는 자료구조로 만든 뒤 프로모션만 존재하는 상품은 프로모션을 null로 하여 새로 추가해주어야 한다. 

 

제출할 때에는 값을 배열로 만들어서 활용했지만, 이번에는 데이터 상단의 정보를 key으로 활용해 객체로 만들어보기로 한다.

const dataToObject = (keys, values) => {
  const obj = {};
  keys.forEach((key, i) => {
    const value = values[i];
    if (value === 'null') {
      obj[key] = null;
      return;
    }
    obj[key] = value;
  });
  return obj;
};

프로모션이 없는 상품 추가

1. Map을 이용해서 name으로 개수를 카운트한다.
2. 개수가 1인 항목만 담은 배열을 만든다.
3. 배열을 순회하며 findIndex를 이용해서 기존 배열에 name이 같은 값의 index를 구하고 splice로 새로운 데이터를 추가한다.

객체로 데이터를 만드는 것까지는 괜찮은데 이제 프로모션 상품은 있지만, 일반 상품이 없는 경우에는 직접 추가를 해주어야 한다. 그 로직을 짜기 위한 방법으로 위 순서를 생각했다.

 

방법 01.

const formatProducts = (data) => {
  const map = new Map();
  data.forEach((v) => {
    map.set(v.name, (map.get(v.name) ?? 0) + 1);
  });

  const singleProducts = [...map].filter(([, count]) => count === 1);
  singleProducts.forEach((v) => {
    const index = data.findIndex((product) => product.name === v[0] && product.promotion !== null);
    if (index > 0) {
      data.splice(index, 0, { ...data[index], promotion: null });
    }
  });

  return data;
};

원하는 대로 데이터는 만들었지만, 현재 로직에서는 큰 문제는 없더라도 splice로 배열을 직접 수정하게 되면서 불변성을 지키지 않고 있다. 또, index를 찾기 위해 data를 전체 순회해야 한다. 

방법 02.

const formatProducts = (data) => {
  const map = new Map();
  data.forEach((v) => {
    map.set(v.name, (map.get(v.name) ?? 0) + 1);
  });

  const singleProductNames = [...map].filter(([, count]) => count === 1).map(([name]) => name);

  const newProducts = [];
  data.forEach((product) => {
    newProducts.push(product);
    if (singleProductNames.includes(product.name) && product.promotion !== null) {
      newProducts.push({ ...product, promotion: null });
    }
  });

  return newProducts;
};

다른 방법으로는 그냥 새로운 배열을 만들어버리는 것이다. 기존 data를 순회하면서 일단 상품을 추가하고, 1개뿐인 상품이면서 프로모션이 null이 아닌 상품이라면 새로운 객체를 추가해 준다. 우선 불변성을 지킬 수 있고, 원소 개수가 더 많은 data는 한 번만 순회하면서 내부적으로 singleProductNames를 순회하도록 했다. 가독성도 2번 방법이 더 나은 것 같다. 하지만 이 로직도 최선 같지 않다.


MVC요?

3주 차까지는 MVC 패턴까지는 필요하지 않은 간단한 로직이었다. 하지만 4주 차를 기존에 하던 대로 진행했더니 src 폴더 안에 온갖 클래스 파일들이 줄줄이 소시지처럼 생겨나기 시작했다. MVC 패턴이 어떤 건지도 제대로 모른 채로 우선 Model만이라도 분리해 보자며 얼레벌레 나누어 사용했다. 다시 구현해 보면서 처음부터 MVC 패턴을 고려해 보려고 노력했다.

Controller 추가

async run() {
  try {
    Store.initialize();
    OutputView.greeting();
  } catch (error) {
    console.log(error);
  }
}

처음에는 일단 막 갖다 붙였다. 이 코드에서 OutputView는 UI에 속한다. 원래는 App 클래스에서 처리했던 로직들을 옮겨왔다. Store.initialize는 Model의 로직인데 StoreController가 있다면 Model과 View를 연결할 수 있겠다.

class StoreController {
  async execute() {
    try {
      this.#initializeStore();
      OutputView.greeting();

      const cartInput = await InputView.purchase();
      const cart = this.#addToCart(cartInput);
    } catch (error) {
      OutputView.errorMessage(error.message);
    }
  }
 ...
 }

StoreController를 이용해 Model과 View를 연결했다. 막상 사용하고 보니 cart가 여기에 있는 게 맞는지 모르겠다. 이 로직을 또 CartController로 분리해서 감싸니 매번 이렇게 모든 Model을 감싸는 것이 맞는지 고민되었다. 당장은 감싸는 것 외에 복잡한 로직은 없어서 유지했다.


프로모션 계산

이때부터 머릿속이 복잡해지기 시작한다. 대충 생각해 보면 간단한 것 같은데 막상 설계하려고 하면 뭐 이렇게 필요한 값이 많은지 너무너무 헷갈렸다. 천천히 다시 구현해 보았지만, 오히려 이전에 구현해 봤던 기억이 사라지지 않아서 더 어렵게 느껴졌다. 그래서 코드를 다시 옮기는 것보단 제출한 코드와 새로 구현한 코드의 흐름 차이만 정리하려고 한다. 

제출

Calculator 클래스를 두어 내부 메서드로 Promotion을 계산하고, Stock 클래스로 재고 수량을 조정했다. 이때 실수는 무료로 제공하는 수량 계산을 너무 어렵게 접근했다. 최종 영수증 출력에는 총 구매 수량과 무료로 제공된 [상품 - 수량]만 필요했을 뿐인데 계산 과정에서 속성을 붙여서 객체를 내보내고, 거기에 또 붙여서 내보내는 식으로 작성했다.

 

이렇게 흘러간 이유 중 하나는 요구사항 중 1개의 함수가 10줄 이상 넘어가지 않도록 한다는 조건이 있었기 때문이다. 분리하고 또 분리하다 보니 매개변수는 많아지고, 그 값을 추가해서 내보내는 값도 많아지게 되었다. 그래서 여기에 작성하기도 민망한 어마어마한 객체 덩어리인 PromotionResult 클래스가 만들어졌다. 😅 그리고 이 객체 덩어리를 이용해서 멤버십도 계산하고, 또 반환된 값을 Receipt 클래스로 전달해서 최종 영수증으로 출력하는 방향으로 구현했다.

변경

요구사항만 보면 까마득 했지만, 로직 이해를 위해 제한사항은 고려하지 않고 구현해 보았다. 가장 다르게 접근한 부분은 Promotion 계산과 Membership 계산으로 최종 결과만 반환하도록 한 점이다. 프로모션이 가능한 지, 가능하다면 몇 개나 적용할 수 있는지 등을 모두 계산해서 영수증 출력과 멤버십 계산을 위한 증정 수량/일반 결제 수량만을 고려했다. 

  #calculate(cartList) {
    const promotionCalculator = new PromotionCalculator();
    const membershipCalculator = new MembershipCalculator();

    const promotionResults = promotionCalculator.calculate(cartList);
    const membershipResults = membershipCalculator.calculate(cartList);

    return { promotionResults, membershipResults };
  }

StoreController 내부에서 각각 계산을 위한 인스턴스를 활용했다. 이전에 프로모션과 멤버십을 너무 함께 생각해서 더 어렵고, 복잡해졌던 것 같다. 아니면 한번 구현해보고 나니 개선점이 보였을지도 모르겠다. 전반적으로 메서드를 실행 위주로 활용하고 값을 반환하도록 활용하지 못했다는 아쉬움이 있다. 


어려웠던 점

총 4개의 미션을 진행하면서 가장 어렵게 느껴졌던 부분은 콘솔만을 이용한다는 것이었다. 모든 입력 처리가 string 타입으로 처리되면서, 타입스크립트를 사용하지 않다 보니 로직이 복잡해질수록 하나하나 신경 쓰기가 쉽지 않았다. 이 과정에서 가장 도움이 많이 되고, 필요성을 느꼈던 것은 테스트 코드이다. 실제로 타입 에러로 실행되지 않는 문제는 테스트 코드를 통해서 발견하여 수정한 경우가 많았다. 피드백으로 받은 TDD 풀이 영상을 보면 쉬워 보이지만, 막상 하려고 하니 쉽지 않아서 조금 많이(?) 연습이 필요할 것 같다.


마치며

짧지만 결코 짧지 않았던 4주간의 프리코스를 마지막 애매한 회고로 마무리한다. 이전 회고들에 비해 조금 갑작스럽게 마무리를 짓는 것 같은데 현재 새로 구현한 코드도 만족스럽지 않아서 고민을 많이 했다. 요구사항을 읽으면 읽을수록 헷갈리고, 잘 구현하던 중에도 제출했던 코드의 설계가 더 나아 보이기도 했다. 부족한 부분은 계속 보이는데 당장 해야 하는 일들은 쌓여 있어서 무엇을 먼저 해야 할지 멘붕이 오기도 했다. 마무리하는 지금도 조금 멍한 기분이 드는데 그래도 어떻게든 4주 차를 모두 진행했고, 클래스에 당황하지 않고 실행 예시대로 잘 출력해 낸 것만으로도 지금까지 공부한 것들을 잘 활용했다는 생각이 든다. 오를 수 없는 벽처럼 느낀 4주 차에 대한 아쉬움과 테스트 코드를 더 잘 활용하고 싶다는 욕심은 솟아오르지만, 우선은 잠시 멈추었던 프로젝트를 진행하면서 미션들을 다시 공부해 보는 시간을 가져야겠다.