소감 & 회고록

[회고] 2nd 프로젝트: DREAM - KREAM 모델링 c2c 경매 플랫폼 제작

왈왈디 2023. 5. 18. 15:30
728x90

📍2주 동안 7명이서

위코드 부트캠프에서 4월 20일부터 5월 4일까지 2주 동안, 7명의 팀원으로 두 번째 프로젝트를 진행했다.

멤버 구성은 프론트엔드 3명, 백엔드 4명(나 포함). 총 4명으로 진행되었던 1차 프로젝트에 비해 많은 인원과 함께하는 프로젝트라 걱정도 크고 기대도 컸다.

 

인원이 많아지며 가장 크게 달라진 점이 두 가지가 있었다. 그 중 첫 번째는 백엔드 인원이 2명에서 4명으로 늘었다는 점이다.

이번 프로젝트에서 4명이 함께 일하며, 협업에 대해 더욱 깊게 고민하고, 함께 서비스를 만들어갈 체계적인 방법을 찾고자 더 많이 노력하게 되었다. 2명일 때는 옆자리에 앉아 그때 그때 필요한 논의를 할 수 있었으나, 4명이 되니 함께 논의하고 결정할 사항이 있으면 시간을 정하고 회의실을 잡아 모여야했다. 4명이니 합의점을 찾는 시간도 더 오래 걸렸다.  작업 분배도 2명일 때는 이 사람이 하지 않은 업무는 내가 하면 되니 큰 고민이 없었는데, 4명이 되니 누가 어떤 API를 맡을지도 큰 논점이 되었다.

또, github으로 협업하는 것 또한 훨씬 복잡해졌다. 여러 사람이 동시에 pull request를 올리고 다양한 시점에 code들이 merge되니 수시로 main을 업데이트하고 작업 중인 브랜치에 main 브랜치를 rebase하여 conflict를 해결해줘야 했다. 또 그렇기에 내가 실수하면 여러 사람들이 작업에 혼선을 빚게 된다는 생각에 부담감도 더 컸다.

 

두 번째 큰 차이점은 통신해야 하는 프론트엔드 팀원들도 3명이 되었다는 점이다.

이전 프로젝트에서는 거의 1명과 중점적으로 통신을 진행하여, 최대한 그 프론트엔드 팀원이 사용하기 편한 API를 만드는 데에 집중했었다. 그러나 이번 프로젝트에서는 3명 모두와 통신을 진행하게 되었다. 3명의 프론트엔드 팀원들 개개인이 작업 스타일도 다 다르고, 선호하는 응답 데이터의 형태도 다르고, 작업 속도도 달라 각각의 팀원들에게 API 응답 스타일을 모두 맞추는 것은 쉽지 않았다. 이전에는 프론트엔드에게 맞춰주는 것이 최선의 방법이라고 생각했는데, 조금 더 큰 팀에서 작업해보니, 백엔드의 컨벤션을 지켜 서버의 효율을 높이면서도, 3명이 무난하게 편히 사용할 수 있는 API를 설계해야겠다는 생각이 들었다. 또, 통신할 인원이 많아 지다보니 request와 response로 주고 받을 데이터 타입과 key값 등을 어떻게 하면 더 효율적으로 공유할 수 있을까를 더 고민하게 되었다. 

 

이렇게 인원이 늘어나며 업무의 난이도는 더욱 높아졌지만, 확실히 더 다채롭고 더 다양한 기능을 갖춘 서비스를 만들 수 있었던 것 같다. 인원이 더 많은 만큼 더 풍부한 아이디어들이 나왔고, 함께 논의하며 서로의 단점을 보완하고 더 완성도있는 코드들을 짤 수 있었다. 개발자에게 협업 능력은 아주 중요한 부분이라고 생각하는데, 많은 팀원들과 함께한 덕분에 더 효율적인 소통 방법을 찾고 시도해보는 계기가 되었다. 처음의 걱정이 무색하리만큼 계획했던 것 이상으로 기능을 완성하고, 갈등없이 프로젝트가 마무리되어, 어려웠지만 보람찬 프로젝트였다.

 

📍c2c 경매 플랫폼 사이트 KREAM 모델링

이 프로젝트에서 어려웠던 또 다른 부분은 'c2c 경매' 기능을 구현해야 했다는 점이다. 1차 프로젝트에서는 상품 구매 쇼핑몰을 제작했는데, 쇼핑몰은 평소에 내가 많이 이용하기도 하고, user 입장에서도 매우 직관적인 기능들로 구성되어있어 API를 작성하며 서비스 구조 자체가 헷갈리거나 오랜 고민이 필요한 부분은 거의 없었다.

그러나 이번 프로젝트에서 선정한 서비스는 'c2c 경매'가 핵심이었고, 나는 경매를 경험해본 적이 없다. 구매 입찰도 해본 적이 없는데, 판매 입찰자 입장까지 고려하여야 하다 보니 서비스의 구조를 이해하는데에도 시간이 걸렸다. 이는 팀원들도 대부분도 마찬가지였다. 팀원 모두가 이 서비스의 구조를 완벽히 이해하기까지 꽤 긴 시간이 걸렸다. 비즈니스 모델 정리를 위한 회의를 따로 진행하기도 했다.

[비즈니스 모델 회의 - 판매/구매 입찰 플로우]

비즈니스 모델 회의를 진행하기 전에는 이해한 내용이 각각 달라 작업에 혼선이 있기도 했는데, 회의 후 진행이 훨씬 더 수월해졌다. 함께 이야기하는 과정에서 유레카를 외치며 더 확실히 이해되는 부분도 있었다. 집단지성의 위대함과 러버덕의 필요성을 느낀 순간이었다.

[c2c 경매 서비스의 페이지 진행 플로우]

그렇게 우리가 정리한 c2c 경매 서비스의 구조는 위와 같다.  백엔드 API에서는 좀 더 구체적인 비즈니스 로직이 구현되어야 했는데, 그 부분은 '핵심 트러블 슈팅' 파트에서 코드와 함께 살펴보자.

 

📍익숙해진 scrum 방식

지난 프로젝트에 이어 두 번째로 scrum 방식의 프로젝트를 진행하게 되었다. 두 번째인 만큼 Sprint planning 회의도, Dailly standup Meeting(DSM)도, 회고 미팅도 훨씬 더 익숙하고 능숙해졌다. 매일 아침 팀원들이 돌아가며 '어제 한 일, 오늘 할 일, 내일 할 일, 논의 사항'을 공유하는 것이 자연스러웠다. 특히 project manager님이 매번 team notion 페이지에 DSM 회의록 template을 올려 모든 팀원이 함께 작성할 수 있게 하여, 회의 문서화가 더 잘 될 수 있었다.

[Team notion으로 정리한 회의록]

Trello의 티켓도 한 번의 경험이 있기에 더욱 깔끔하게 작업을 분배할 수 있었다. 1차 Sprint planning 미팅에서 API 기준으로 'Backlog'에 티켓을 모두 생성하고, 해당 sprint 기간 동안 끝내고자하는 티켓들을 'This Sprint'로 옮긴다. 지난 프로젝트 때 팀 스스로를 과소평가하여 'This Sprint'로 넣었던 티켓들을 sprint 중반에 모두 끝내버렸었기에, 이번에는 sprint별 목표를 그보다 높게 설정하였다. 그렇게 하니 sprint를 꽉 채워 목표를 100% 달성할 수 있었다. 목표를 다소 높이 설정하니, 달성을 위해 더 열심히 작업하게 되어 만족스러웠다. 업무가 효율적으로 분배된 덕에 목표했던 기능을 모두 구현할 수 있었던 것 같다. 

[2차 프로젝트 최종 Trello 티켓]

다만, 아쉬웠던 점은 일을 빨리 진행시키고 싶은 마음이 앞서 API 작성 후 테스트를 꼼꼼히 진행하지 않고 branch를 github 상에 push 하여 main에 merge된 후 오류를 발견하고, 수정 branch를 다시 만들어 코드를 수정하는 일이 여러번 있었다는 것이다. 목표한 달성률을 이루려는 욕심이 컸다. 이 일로 팀원들도 작업에 불편함을 겪고, 나도 이미 main branch에 merge된 코드이기에 더 빨리 수정해야 한다는 압박으로 실수를 반복하게 되었다. 첫 번째 API를 작업하며 일어난 일이라, 이후 API들을 작성할 때에는 에러가 발생하지 않는지 꼼꼼히 확인하고 더 신중하게 코드를 push하게 되었다. 이번 프로젝트 뿐만 아니라 앞으로도 API가 다양한 상황에서 정상적으로 작동하는지 꼼꼼히 test하고 예외 처리, 에러 핸들링하는 것에 더 많은 주의를 기울여야겠다고 생각했다.

 

📍API Documentation - Postman

프로젝트를 진행하며 프론트엔드와의 통신을 위해 key값과 데이터 타입 등을 공유하는 방법으로 postman의 API documentation을 활용했다. 작성한 documentation을 publish하고 링크를 팀 슬랙에 공유했다. 프론트엔드 팀원에게 덕분에 작업하기 매우 편했다는 피드백을 들어 뿌듯했다.

[frontend와 postman documentation으로 소통]

프로젝트를 마치고 Backend 팀원들이 각자 작성한 API를 모두 정리하여 하나로 합쳐진 API documentation을 완성했다. 서비스 전체의 API가 한 눈에 들어오고 쉽게 이해되어 프론트, 백 팀원 모두에게 좋은 방식이 되었다고 생각한다.

 

📍ERD

복잡한 비즈니스 로직 덕분에 ERD를 설계하는 것도 쉽지 않았다. 가장 고민되었던 부분은, [구매 입찰(buyings), 판매 입찰(sellings), 거래 성사(deals)] 내역을 어떻게 분리하고 서로 연결시키느냐 였다. '구매 입찰'과 '판매 입찰'은 필드 구성이 동일하기에 하나의 테이블로 만들어 구매인지 판매인지 구분하는 필드를 넣을까 싶었으나, 두 테이블을 분리하는 것이 추후 구매와 판매 테이블의 구성이 달라질 수 있다는 점을 고려하여 더 낫다고 판단했다.

거래 테이블상품 id를 넣어야 하는지, 거래 체결가(bid price)을 넣어야 하는지 고민했으나, 최대한 데이터가 중복되어 저장되지 않도록 하자는 결론을 내, 구매 입찰 id판매 입찰 id를 넣어 table join으로 해당 데이터들을 끌어올 수 있도록 하고, 거래 테이블에는 직접적으로 그 필드들을 넣지 않기로 결정했다.

[DREAM의 ERD]

📍Unit Test

이번 프로젝트에서 처음으로 Jest를 이용하여 unit test를 진행해 보았다. 작성한 모든 API에 대하여 진행한 것은 아니었지만, test 코드의 필요성과 편리함을 충분히 느낄 수 있는 경험이었다. 매번 코드를 수정하고 postman에서 실행해보는 것보다, 잘 짜둔 test code를 실행하여 vs code에서 바로 바로 코드에 이상이 없는지 확인하는 게 더 빠르고 편리했다.

또, 사실 상용화될 서비스는 무엇보다도 서버가 다운되지 않도록 최대한 꼼꼼히 에러와 예외처리 하는 것이 중요한데, test code를 만들면서 기존에는 생각지 못했던 다양한 예외처리를 하게 되었다. 

다만, 문제는 test 코드 작성하는 게 아직 익숙하지 않아서인지, test 코드 작성하는 시간이 너무 길어 API 작성하는 시간보다 훨씬 더 걸렸다는 점이다. 추후 여유가 있을 때 1차, 2차 프로젝트의 모든 기능들에 대한 unit test 코드를 작성해보고 싶다.

[unit test 실행 결과]

 

📍핵심 트러블 슈팅

가장 중요한 코드에 대해 얘기를 안 할 수 없다. 사실 이번 프로젝트는 나에게 배운 것도 많고 탈도 많은 프로젝트였다. 여러가지 어려운 점이 많았지만, 가장 크게 두 가지 트러블이 있었고, 그걸 슈팅하는 데에 굉장히 긴 시간을 들여 애를 썼다. 하나씩 살펴보자.

1. 상품 상세 정보 호출 API - SELECT 쿼리문의 오사용

[가장 먼저 merge 되었으나 무려 4번이나 Fix된 나의 API]

위에서 이야기한, 에러가 있으나 main 브랜치에 merge되어 버렸던 코드가 바로 이 녀석이다. 상품의 상세 페이지에 사용되는, 상품 id에 따라 상세 정보를 호출하는 API 였는데 일반적인 상품 상세 정보 API와 다른 점은, 해당하는 상품의 ['최근 거래가','즉시 구매가', '즉시 판매가'] 각각의 정보를 입찰 내역 테이블에서 조건에 맞게 불러와야 한다는 것이었다. 

사실 저 세 수치를 정확한 논리로 불러오는 것은 쉬운 일이 아니었는데, 상품 상세 정보 응답 API는 간단하다는 생각으로 접근하여 안일하게 각 입찰 테이블, 상품 테이블, 거래 테이블에 데이터를 하나씩만 넣고 데이터가 잘 호출되는 것만 확인하여 코드를 push한 것이 화근이었다.

 

아래가 처음 작성하여 push했던 문제의 query문이다.

//API/models/productDao.js
const productDetail = async (productId) => {
  try {
    const [productDetail] = await appDataSource.query(
      `
        SELECT 
            p.name productName,
            p.model_number modelNumber,
            c.name categoryName,
            p.original_price originalPrice,
            pi.url imageUrl,
            pa.age productAge,
            pl.level productLevel,
            (SELECT 
                b.bid_price
            FROM buyings b
            JOIN deals d
            ON d.buying_id = b.id
            WHERE d.created_at = (SELECT max(created_at) FROM deals)) recentDealPrice,
            (SELECT 
                bid_price
            FROM sellings
            WHERE bid_price = (SELECT min(bid_price) FROM sellings WHERE bid_status_id = 1)) buyNowPrice,
            (SELECT 
                bid_price
            FROM buyings
            WHERE bid_price = (SELECT max(bid_price) FROM buyings WHERE bid_status_id = 1)) sellNowPrice,
            (SELECT 
              COUNT(user_id)
            FROM likes
            GROUP BY product_id) likeCount
        FROM products p
        JOIN categories c ON p.category_id = c.id
        JOIN product_images pi ON p.id = pi.product_id
        JOIN product_ages pa ON p.product_age_id = pa.id
        JOIN product_levels pl ON p.product_level_id = pl.id
        LEFT JOIN buyings b ON b.product_id = p.id
        LEFT JOIN sellings s ON s.product_id = p.id
        LEFT JOIN likes l ON l.product_id = p.id
        WHERE p.id = ?
    `,
      [productId]
    );
    return productDetail;
  } catch (err) {
    err.message = 'DATABASE_ERROR';
    err.statusCode = 400;
    throw err;
  }
};

SELECT문에 subquery를 사용하여 구현했는데, 다시 보니 허술하기 짝이 없다.

WHERE절로 각 데이터의 '입찰 금액', '생성 날짜' 필드에 min, max를 걸어 그와 같은 값들을 필터링 했는데, 하나의 row만 추출되어야 하는 SELECT문의 sub-query가 저렇게 되면 하나의 값만 나올리가 없다. 데이터가 많다면, 같은 '입찰 금액/생성 날짜'를 가져 where절에 걸리는 데이터가 얼마나 많을까. 하나씩만 데이터를 넣어뒀으니 그 사실을 몰랐다. 

그리고 사실 저 코드에서 likeCount 값도 마찬가지로 문제가 있었다.

 

그 후 sub-query를 SELECT 대상에 넣지 않고, likeCount의 경우 JOIN 테이블로 sub-query를 넣어 문제를 해결하고, ['최근 거래가','즉시 구매가', '즉시 판매가']를 구하는 것은 하나의 쿼리문 안에 넣기에는 복잡도가 너무 높고, 추후 재사용해야 한다고 판단하여 별도의 함수로 분리하게 되었다.

 

아래가 세가지 가격 정보를 제외한 productDao의 상품 상세 정보 호출 함수다. 

//API/models/productDao.js
//3가지 금액 정보를 제외한 상품 디테일 정보 호출 함수 최종 ver.

const productDetail = async (productId) => {
  try {
    const [productDetail] = await appDataSource.query(
      `
        SELECT 
            p.id productId,
            p.name productName,
            p.model_number modelNumber,
            c.name categoryName,
            p.original_price originalPrice,
            pi.url imageUrl,
            pa.age productAge,
            pl.level productLevel,
            l.likeCount
        FROM products p
        JOIN categories c ON p.category_id = c.id
        JOIN product_images pi ON p.id = pi.product_id
        JOIN product_ages pa ON p.product_age_id = pa.id
        JOIN product_levels pl ON p.product_level_id = pl.id
        LEFT JOIN (SELECT 
            product_id,
            COUNT(id) likeCount
         FROM likes
         GROUP BY product_id) l ON l.product_id = p.id
        WHERE p.id = ?
              `,
      [productId]
    );
    return productDetail;
  } catch (err) {
    throw new DatabaseError('DATABASE_ERROR');
  }
};

세 가격 정보 조회 함수는 별도로 만들었다.

그런데 이 부분이 또 아래 두 번째 트러블슈팅과 이어지게 된다. 이 세 함수를 하나의 클래스의 메서드로 만들고자 한 것이다.

//API/models/bidDao.js
const appDataSource = require('./appDataSource');
const { bidStatusEnum } = require('./enum');
const { DatabaseError } = require('../utils/error');

class BidCase {
  constructor(productId, bidType, bidPrice) {
    this.bidType = bidType;
    this.bidPrice = bidPrice;
    this.productId = productId;
    this.counterpart = bidType == 'buying' ? 'selling' : 'buying';
    this.commissionRate = bidType == 'buying' ? 0.02 : 0.05;
    this.table = `${bidType}s`;
    this.counterTable = `${this.counterpart}s`;
    this.minOrMax = this.counterpart == 'selling' ? 'min' : 'max';
    this.appDataSource = appDataSource;
  }

 async nowPriceSetter(productIdValue, table, minOrMax) {
    try {
      const [bidPrice] = await this.appDataSource.query(
        ` 
        SELECT 
            bid_price bidPrice
        FROM ${table}
        WHERE bid_price = (
          SELECT ${minOrMax}(bid_price) 
          FROM ${table} 
          WHERE bid_status_id = ${bidStatusEnum.bid} 
          AND product_id = ${productIdValue}) 
        `
      );

      if (bidPrice == undefined) {
        return null;
      }

      return parseFloat(Object.values(bidPrice));
    } catch (err) {
      throw new DatabaseError('DATABASE_ERROR');
    }
  }

  getBuyNowPrice() {
    return this.nowPriceSetter(this.productId, 'sellings', 'min');
  }

  getSellNowPrice() {
    return this.nowPriceSetter(this.productId, 'buyings', 'max');
  }

  async getNowPrice() {
    return this.nowPriceSetter(
      this.productId,
      this.counterTable,
      this.minOrMax
    );
  }

  async getRecentDealPrice() {
    try {
      const [bidPrice] = await this.appDataSource.query(
        ` 
            SELECT 
                b.bid_price AS bidPrice
            FROM deals d
            JOIN buyings b ON b.id = d.buying_id
            WHERE d.created_at = 
            (SELECT max(d.created_at) 
            FROM deals 
            JOIN buyings b ON b.id = d.buying_id 
            WHERE b.product_id = ${this.productId}) 
            AND b.product_id = ${this.productId}
            ORDER BY d.created_at DESC
            `
      );

      if (bidPrice == undefined) {
        return null;
      }

      return parseFloat(Object.values(bidPrice));
    } catch (err) {
      throw new DatabaseError('DATABASE_ERROR');
    }
  }
 };

클래스에 관하여 자세한 부분은 아래에서 다루고, 세 거래가 정보는 상품 자체보다는 경매 입찰과 관련 높은 정보라고 판단되어

관련 함수들을 productDao가 아닌 bidDao에 작성했다.

 

결국 이 모든 정보들을 합쳐 하나의 응답으로 클라이언트에게 보내줘야 했기에 아래와 같이 productService를 작성했다.

//API/services/productService.js
const getProductDetail = async (productId) => {
  const checkProductId = await productDao.isExistingProduct(productId);
  
  const productDetail = await productDao.productDetail(productId);
  const bidCase = new BidCase(productId);

  productDetail.buyNowPrice = await bidCase.getBuyNowPrice();
  productDetail.sellNowPrice = await bidCase.getSellNowPrice();
  productDetail.recentDealPrice = await bidCase.getRecentDealPrice();
  productDetail.premiumPercent = (
    ((productDetail.recentDealPrice - productDetail.originalPrice) /
      productDetail.originalPrice) *
    100
  ).toFixed(1);

  return productDetail;
};

이렇게 작성 후에, premiumPercent 쪽에서 또 오류가 났다.

살펴보니 거래 체결된 내역이 없어 '최근 거래가'가 형성되지 않은 경우 계산이 불가능 해 오류가 나는 것이었다.

그래서 아래와 같이 코드를 수정해줬다.

//API/services/productService.js  
//수정 전
    productDetail.premiumPercent = (
    ((productDetail.recentDealPrice - productDetail.originalPrice) /
      productDetail.originalPrice) *
    100
  
//수정 후
  productDetail.recentDealPrice == null
    ? (productDetail.premiumPercent = null)
    : (productDetail.premiumPercent = (
        ((productDetail.recentDealPrice -

사실 이후 관련 코드를 자잘하게 여러 부분 수정하였지만, 여기까지 우선 작동은 했기에 이렇게 마무리가 되었다.

복잡한 로직을 지나치게 단순하게 생각한 것과, 다양한 경우의 수를 테스트 해보지 않은 것, 코드의 재활용성을 고려하지 못하고 하나의 쿼리문으로 작성하려한 것이 가장 큰 문제였던 것 같다. 

 

무엇보다도 빨리 많은 코드를 쓰려고 하는 마음이 가장 문제가 되지 않았나 싶다. 

이후에는 빨리 만드는 것도 좋지만 정확하고 정교한 API를 만들어내는 것이 더 중요하다는 생각이 들었다.

 

'정확성, 예외처리, 다양한 환경에서의 테스트, 코드의 재활용성'의 중요성을 이번 트러블슈팅을 통해 뼈저리게 배웠다.

 

2. 경매 입찰 API - API class화

부트캠프에서 class에 대해서 어렴풋이는 배웠으나, 제대로 활용해본 적은 없었다. 다만, class에 있어 중요한 것은 객체와 함수가 하나로 합쳐진 형태이며 class내의 함수들은 class 내의 property 값들을 변수로 자유롭게 이용할 수 있다는 것이었다. 

 

ERD를 설계하면서부터 구매/판매 입찰 API 구현을 맞게 되어서도, [판매 입찰, 구매 입찰] 둘은 거의 동일하지만 입장만 다르다는 점에서 '어떻게 하면 하나의 테이블 또는 함수를 재활용하여 효율성을 높일 수 있을까'를 가장 깊이 고민했다.

 

사실 다 끝나고 나서 보니 두 경우를 분리하여 각각을 위한 함수를 만드는 것이 여러모로 더 효율적이었을 것이라는 생각이 들지만, 이 때 내가 가장 적합하다고 판단한 방식은 두 입찰을 위한 class를 만드는 것이었다. 

 

'구매' 입찰을 할 경우 '판매 입찰'된 입찰 금액들 중 가장 낮은 금액이 '즉시 구매가'가 되고, '판매' 입찰을 하는 경우 가장 높은 '구매 입찰가'가 즉시 판매가가 된다. 이렇게 취하는 입장에 따라 호출해야 하는 테이블이 달라지는 상황에서 이를 하나의 API로 구현하려다보니 우선은 '판매/구매' 입찰 유형 구분자를 requeset의 property로 받았다. 그리고 각 경우에 따라 객체에서 해당하는 값을 불러와 그 값을 사용하려고 했다. 하지만 그렇게 되면 만들어야 하는 참조 객체가 너무 많아져 혼란스럽다는 생각이 들었다.

그렇게 객체와 함수를 함께 쓸 수 있는 class를 만들어야겠다는 생각을 하고 위에서 얘기한  ['최근 거래가','즉시 구매가', '즉시 판매가'] 호출 함수부터 class에 집어넣기 시작했다. 그게 1번의 bidDao 내 class이다.

 

이때까지는 복잡성도 그리 높지 않고 의도했던 대로 함수 재활용도 하며 코드가 더 깔끔해졌다고 생각했다.

그러나 이후 class화의 본래 목적이었던 입찰 API를 작성하며 다양한 함수들이 엮이고, 여러 쿼리문을 transaction 처리도 해야 한다는 점을 한창 작성 중에 깨달으며 뭔가 잘못됐다고 느꼈다. class의 복잡성이 너무 높아졌다는 생각이 들었다. 

 

또 당시 처음 작성해보는 class였기에, class에 대한 개념부터가 확실히 잡히지 않아 작성하는 데에 시간이 매우 오래 걸렸다. 약 4일 동안은 밥먹고 잠자는 시간 외에는 class 생각만 했던 것 같다. 그렇게 완성한 입찰 API class는 아래와 같다.

//API/models/bidDao.js

class BidCase {
  constructor(productId, bidType, bidPrice = null, dueDate = null, userId) {
    this.bidType = bidType;
    this.bidPrice = bidPrice;
    this.productId = productId;
    this.counterpart = bidType == 'buying' ? 'selling' : 'buying';
    this.commissionRate = bidType == 'buying' ? 0.02 : 0.05;
    this.counterCommissionRate = bidType == 'buying' ? 0.05 : 0.02;
    this.table = `${bidType}s`;
    this.counterTable = `${this.counterpart}s`;
    this.minOrMax = this.counterpart == 'selling' ? 'min' : 'max';
    this.dueDate = dueDate;
    this.userId = userId;
    this.appDataSource = appDataSource;
  }

  async nowPriceSetter(productIdValue, table, minOrMax) {
    try {
      const [bidPrice] = await this.appDataSource.query(
        ` 
        SELECT 
            bid_price bidPrice
        FROM ${table}
        WHERE bid_price = (
          SELECT ${minOrMax}(bid_price) 
          FROM ${table} 
          WHERE bid_status_id = ${bidStatusEnum.bid} 
          AND product_id = ${productIdValue}) 
        `
      );

      if (bidPrice == undefined) {
        return null;
      }

      return parseFloat(Object.values(bidPrice));
    } catch (err) {
      throw new DatabaseError('DATABASE_ERROR');
    }
  }

  getBuyNowPrice() {
    return this.nowPriceSetter(this.productId, 'sellings', 'min');
  }

  getSellNowPrice() {
    return this.nowPriceSetter(this.productId, 'buyings', 'max');
  }

  async getNowPrice() {
    return this.nowPriceSetter(
      this.productId,
      this.counterTable,
      this.minOrMax
    );
  }

  async getRecentDealPrice() {
    try {
      const [bidPrice] = await this.appDataSource.query(
        ` 
            SELECT 
                b.bid_price AS bidPrice
            FROM deals d
            JOIN buyings b ON b.id = d.buying_id
            WHERE d.created_at = 
            (SELECT max(d.created_at) 
            FROM deals 
            JOIN buyings b ON b.id = d.buying_id 
            WHERE b.product_id = ${this.productId}) 
            AND b.product_id = ${this.productId}
            ORDER BY d.created_at DESC
            `
      );

      if (bidPrice == undefined) {
        return null;
      }

      return parseFloat(Object.values(bidPrice));
    } catch (err) {
      throw new DatabaseError('DATABASE_ERROR');
    }
  }

  async isNowPrice() {
    const nowPrice = await this.getNowPrice();
    if (
      nowPrice &&
      ((this.bidType == 'buying' && this.bidPrice - nowPrice >= 0) ||
        (this.bidType == 'selling' && this.bidPrice - nowPrice <= 0))
    ) {
      this.bidPrice = nowPrice;

      return this.bidPrice;
    }

    return false;
  }

  async isExistingBid() {
    try {
      const [result] = await appDataSource.query(
        `SELECT EXISTS (
            SELECT
            id
            FROM ${this.table}
            WHERE user_id = ${this.userId} 
            AND product_id = ${this.productId} 
            AND bid_status_id = ${bidStatusEnum.bid}
            ) existing 
            `
      );
      return !!parseInt(result.existing);
    } catch (err) {
      throw new DatabaseError('DATABASE_ERROR');
    }
  }

  async biddingIn() {
    const queryRunner = this.appDataSource.createQueryRunner();
    await queryRunner.connect();
    await queryRunner.startTransaction();
    try {
      await this.isNowPrice();
      if (await this.isExistingBid()) {
        await queryRunner.query(
          `UPDATE ${this.table}
        SET bid_price = ?,
            due_date = ?
        WHERE user_id = ? 
        AND product_id = ? 
        AND bid_status_id = ?`,
          [
            this.bidPrice,
            this.dueDate,
            this.userId,
            this.productId,
            bidStatusEnum.bid,
          ]
        );

        const [bidding] = await queryRunner.query(
          `SELECT
            id
        FROM ${this.table}
        WHERE user_id = ${this.userId} 
        AND product_id = ${this.productId} 
        AND bid_status_id = ${bidStatusEnum.bid}
        `
        );
        this.biddingId = bidding.id;
      } else {
        const bidding = await queryRunner.query(
          ` INSERT INTO ${this.table} (
                product_id,
                bid_price,
                due_date,
                user_id
                )
                VALUES (?, ?, ?, ?)`,
          [this.productId, this.bidPrice, this.dueDate, this.userId]
        );

        this.biddingId = bidding.insertId;
      }

      if (!(await this.isNowPrice())) {
        await queryRunner.commitTransaction();
        return;
      }

      const [partner] = await queryRunner.query(
        `SELECT
        id,
        user_id userId 
        FROM ${this.counterTable}
        WHERE updated_at = 
        (SELECT 
            min(updated_at)
        FROM ${this.counterTable}
        WHERE product_id = ${this.productId} 
        AND bid_price = ${this.bidPrice} 
        AND bid_status_id = ${bidStatusEnum.bid})
        AND product_id = ${this.productId} 
        AND bid_price = ${this.bidPrice} 
        AND bid_status_id = ${bidStatusEnum.bid}
        ORDER BY updated_at`
      );

      if (partner.userId == this.userId) {
        throw new DatabaseError('SAME_USER_WITH_COUNTERPART');
      }

      await queryRunner.query(
        `UPDATE ${this.table} t
        JOIN ${this.counterTable} c
        SET t.bid_status_id = ${bidStatusEnum.deal},
            c.bid_status_id = ${bidStatusEnum.deal}
        WHERE t.id = ${this.biddingId} 
        AND c.id = ${partner.id}`
      );

      const dealInput = await queryRunner.query(
        ` INSERT INTO deals (
            ${this.bidType + '_id'},
            ${this.counterpart + '_id'},
            ${this.bidType + '_commission'},
            ${this.counterpart + '_commission'}
            )
            VALUES (?, ?, ?, ?)`,
        [
          this.biddingId,
          partner.id,
          this.commissionRate * this.bidPrice,
          this.counterCommissionRate * this.bidPrice,
        ]
      );

      [this.dealInfo] = await queryRunner.query(
        `
        SELECT
            id,
            deal_number dealNumber
        FROM deals
        WHERE id = ?`,
        [dealInput.insertId]
      );

      await queryRunner.commitTransaction();

      return;
    } catch (err) {
      await queryRunner.rollbackTransaction();
      err.message = err.message || 'DATABASE_ERROR';
      err.statusCode = 400;
      throw err;
    } finally {
      await queryRunner.release();
    }
  }

이 중 입찰 기능을 위한 메인 함수는 biddingIn()이다. 

열심히 4일 밤낮으로 작성했지만, class의 constructor, private, public, 상속, static 메서드 등 잘 몰랐던 개념이 많았어서 class로서도 아쉬운 부분이 많은 코드다.

 

service 함수에서는 아래와 같이 인스턴스를 생성하여 실행시켰다.

//API/services/bidService.js
const inputBidPrice = async (productId, bidType, bidPrice, dueDate, userId) => {
  const bidCase = new BidCase(productId, bidType, bidPrice, dueDate, userId);

  return await bidCase.biddingIn();
};

어려움과 아쉬움이 컸던 코드지만, 잘 몰랐던 class에 대해 오랫동안 고민하고 공부할 수 있어 좋았다. 객체 지향 프로그래밍으로 나아가는 데에 발판이 된 경험이라고 생각한다. 

지금 Typescript와 NestJS로 또 다른 프로젝트를 진행하고 있는데, 객체지향성이 높은 NestJS를 익히는 데에 매우 큰 도움이 되고 있다.


이렇게 탈도 많고 배운 것도 많았던 두 번째 프로젝트가 막을 내렸다.

어려움을 겪으며 개발자로서 한 발 더 나아가게 되었다는 생각이 든다.

앞으로도 고난을 두려워하지 않고 즐기며 나아가는 개발자가 되어야지. 힘든 만큼 성장하는 것 같다.

 

프로젝트 저장소 링크: https://github.com/walwald/44-2nd-Dream-backend

 

GitHub - walwald/44-2nd-Dream-backend: wecode 2차 프로젝트: c2c 경매 플랫폼 웹사이트 클론 코딩

wecode 2차 프로젝트: c2c 경매 플랫폼 웹사이트 클론 코딩. Contribute to walwald/44-2nd-Dream-backend development by creating an account on GitHub.

github.com

 

728x90