[1차 프로젝트] 다신코(다신샵 클론코딩)

프로젝트 소개

다신코 보러가기 GITHUB
다신코 말고 진짜 다신샵 구경가기

프로젝트 제안 발표때 제안했던 다신샵이 1차 프로젝트에 당첨(?)됐다. 클론 코딩을 할 사이트를 찾는 일부터 쉽지 않았다. 평소에는 앱을 많이 사용하고, 위코드를 시작한 이후로는 특히 구글 검색창 말고는 다른 사이트를 들어가볼 여유가 없었기 때문이다. 더듬더듬 지금까지 이용해본 사이트를 찾아서 들어갔고, 2주 안에 할 수 없을 것 같은 사이트를 제외하고 나니 리유저블 컴포넌트로 사용할 수 있을만한 것들이 많이 보이는 다신샵이 목록에 남았다. 물론 '보이는게 다가 아니다'라는 것을 깨닫기 이전의 나의 생각이다.

이번 프로젝트에서는 지금까지 학습한 React와 Node.js를 바탕으로 쇼핑몰의 필수 기능을 구현했다. 처음 보는 기능을 구현하다 보니 자연스럽게 자바스크립트를 다양하게 활용하볼 수 있었고, 자바스크립트의 '고수'가 되지는 못했으나 '자생아(자바스크립트 신생아)'에서 '자린이(자바스크립트 어린이)'로 승격할 수 있던 2주를 보냈다. '보이는게 다가 아니다'라는 것을 깨닫고는 숨은 기능을 구현하기 위해 (자린이의 시선에서) 5명이 500인분을 해내는 기적의 협업을 했다.

사용된 기술

  • FRONTEND : React
  • BACKEND : Node.js, Express, Prisma, MySQL, POSTMAN, bcrypt, JWT
  • 라이브러리가 아닌 나의 눈과 손과 머리 : 애ㅈㅡ...정하는 자바스크립트❤️

구현 기능과 역할

  • FRONTEND
    • HEADER
      • 상품 검색창 기능 구현
      • 장바구니 상품 개수 아이콘 기능 구현
    • FOOTER
      • 특정 시간에만 상담 버튼 클릭 가능 기능 구현
    • NAVBAR
      • 드롭다운 navbar 구현
      • 메인 카테고리 HOVER시 해당하는 서브 카테고리로 바뀌는 기능 구현
    • 캐러셀
      • 이미지 캐러셀 기능 구현
    • 카운트다운
      • 특정 상품 할인 종료 카운트 다운 구현
    • 사이드바
      • 제품 조회시 로컬 스토리지에 상품 저장 기능 구현
      • 로컬 스토리지에 있는 상품 사이드바에 보여주는 기능 구현
      • 사이드바 상품 삭제 기능 구현
    • 상품상세페이지 이미지 변경 기능
      • 이미지 HOVER 메인 이미지 변경 기능 구현
      • 자동으로 이미지 변경되는 기능 구현
  • BACKEND
    • 상품 리스트 API
      • 메인 카테고리, 서브 카테고리 별로 조회 가능한 API 구현
      • best, new, 배송 종류 별로 조회 가능한 API 구현
      • 검색창에 입력된 단어를 포함한 상품 리스트 조회 가능한 API 구현
  • TEAM DASHINCO
    • 진행 상황 notion에 적고 공유해달라고 조르는 POWER J
    • 10시 팀미팅 알람(feat.@channel 소환)
    • 질문과 답변 봇
    • 팀 관련 사항은 재빠르게 적고 재빠르게 팀원에게 공유
    • 다크서클이 내려올수록 올라가는 기적의 텐션 시전
    • 나를 위한, 그리고 팀원을 위한 우선순위 정리와 리마인드
    • 코린이의 우당탕탕 더블체크(지식이 정확하고 깊어야 더블체크도 의미가 있다는 의미에서 우당탕탕..!)

회고

프로젝트 기간에 생각은 사치, 이제서야 돌아보는 나

인생은 한 번뿐 오늘만 살자는 주의는 아니지만, 인생은 한 번 뿐이니 하루 하루의 가치를 온전히 느끼며 살아가자는 생각에 습관적으로 나의 하루에 의미부여를 한다. 그 날 하루가 최선이든 최악이든 모두 경험이고 의미가 있었다. 하지만, 지금까지 이토록 하루를 돌아볼 시간 1분이 없어 기절하듯 잠든 나날들은 처음이다. 개발을 하기 위해 배우던 날은 하루를 일주일처럼 잘게 쪼개 알차게 사용한다는 느낌이었다면, 프로젝트 기간은 밤/낮과 집중/휴식의 개념을 잃고 일주일을 하루같이 보낸 느낌이다. 학습의 자유만 주어지다 팀원으로서의 책임이 주어졌기 때문인데, 시간이 가는지도 모를 만큼 전에 없이 몰입을 했다는 뜻이니까 일상의 패턴이 깨진 지금 이 순간이 뿌듯하다. 포기하지 않고 해냈기 때문에 다음부터 이정도의 피곤함은 가뿐하게 이겨낼 수 있을 듯하다.

프로젝트 시작 전에는 학습 가이드라인을 보고 따라가고 모르면 도움을 요청해 해결하면 됐다. 하지만 프로젝트는 팀마다 기능이 다르기 때문에 팀 내에서 모든 것을 해결한다는 점에서 난감했지만, 난감했던 만큼 문제를 해결하는 능력치가 수직 상승했다. 한편으로는 개발의 ㄱ도 모를 때 뱉었던 말을 주워담고 싶을 만큼 부끄러웠다. 홈페이지 수정해달라고 계속 요청을 하던 나, 서비스를 이용하며 많은 불만을 품고 있던 나, 왜 조금 더 공감능력을 끌어모아 개발자의 노고를 깊이 이해하지 못했을까? 보여주는게 하나라면 뒤에는 백 가지, 혹은 천 가지의 노력이 있어야 한다는 사실을 왜 몰랐을까? 아는 만큼 보인다는 말은 괜히 있는 말이 아니다. 아는 만큼만 보는 나의 '제가 하겠습니다'라는 말은 재앙이다. 나의 현재 위치를 잘 파악하고 겸손한 마인드를 바탕으로 현실적으로 생각하자. 겸손.. 마치 우주에 비하면 먼지보다 작은 존재이듯, 방대한 개발의 세계에서 나는 굉장히 작은 존재다. 배움에는 끝이 없다.

개발자의 협업은 LIKE 뫼비우스의 띠 OR 다람쥐 쳇바퀴 돌듯

개발자의 협업은 뭔가..다르다. 그 '뭔가'는 뭘까?

경험은 부족하지만, 지금까지 나는 여러 형태의 협업을 해봤다. 교환학생을 가서 외국인 친구들과 하던 프로젝트, 친구와 경영학 수업을 듣다 창업해본 의류 블로그 마켓, 몇 달간 준비한 해외봉사 프로젝트, 규모가 크지 않은 의류 매장 스텝, 스타트업 팀프로젝트의 A TO Z를 겪은 인턴생활. 그 무엇도 개발자의 협업처럼 고려할 사항이 많고 섬세해야 하며 항상 모든 팀원이 up-to-date여야하는 상황은 없었다.

개발자의 협업은 ‘공유’가 그 무엇보다 중요하다고 느꼈다. 사소한 것이라고 생각해 내용 공유를 하지 않고 넘어가면 언젠가 어디선가 꼭 오류가 발생했다. 처음에 공유했다면 명령어 한 번이면 해결할 것도, 나중에 잡으려니 어디서 발생한지도 모르겠고 난감함 그 자체였다. (하지만 소환하면 항상 같이 오랫동안 고민해주신 팀원들 덕분에 살아남아 회고록도 작성합니다.. 고미사(고맙고..미안하고..사랑합니다..)🥺) 또, 코드를 처음부터 알기 쉬운 구조로 최대한 간단하게 작성해야 작업량이 계속 쌓일 때 팀원간 빠르게 리뷰하고 머지할 수 있다는걸 몸소 느꼈다.

이번 프로젝트는 프론트엔드와 백엔드 모두 작업했는데, 내가 작성한 프론트엔드에 대한 API는 다른 팀원이 만들었다. 각각 따로 만들어볼 때는 몰랐지만, 프론트엔드와 백엔드로 나뉜 개발 체계는 뫼비우스의 띠 마냥 끊임 없이 돌고 돈다. 프론트를 수정하면 백엔드를 수정해야 하고, 백엔드를 수정하면 이에 맞춰 프론트도 수정해야 하고, 무엇 하나 놓치게 되면 브라우저에는 오류화면이 가득해지니 그만큼 소통이 중요하다는 것을 알게 되었다. 이를 깨달았기에, 앞으로는 프론트엔드를 맡든 백엔드를 맡든 협업하는 팀원을 고려해 처음부터 코드를 보다 가독성 좋고 다루기 쉽게 작성할 수 있을 것 같다.

아쉬운 점과 다음 프로젝트를 기다리는 마음가짐은?

구현해야할 기능이 많아 한 가지를 깊게 파고들지 못한 점이 가장 아쉽다. 리팩토링 기간에 CSS 1px도 다시 맞추고, 프론트엔드와 백엔드를 연결하며 백엔드와 연결하기 전처럼 작동하지 않는 코드들을 수정하고, 예외 처리도 더 많이 하겠지만, 작성한 코드를 리팩토링하고 완전한 내 것으로 만드는데는 시간이 조금 걸릴 것 같다. 리팩토링 기간에 최대한 일주일 전의 나를 이해하고 2차 프로젝트 기간에는 이해한 내용을 바탕으로 한 가지의 기능을 만들더라도 깊이있고 완벽하게 만들어내고 싶다.

소속한 팀의 컨디션 지킴이가 되고 싶다. 점심시간에 살기 위해 먹는 것이 아닌 맛을 음미할 수 있는 여유로운 분위기를 만들고, 우리의 다크서클이 턱까지 내려오기 전에 진행상황을 공유해 태스크를 조율하고, 쉬고 오도록 챙겨줄 수 있는 세심함을 장착하고 싶다. 이런 여유를 갖기 위해서는 일단 내가 맡은 일을 잘 해낼 수 있어야 할 것이다. 맡은 일을 열심히 하면서 손과 눈에 여유는 없을지라도 팀원들과 마음에는 조금의 여유를 두고 싶다.

기록하고 싶은 코드

HEADER

다신샵 검색창은 페이지를 리로딩 할 때마다 input의 placeholder가 변경되었다. 배열에 대표 단어들을 담아두고 랜덤으로 placeholder에 들어갈 수 있게끔 만들어 구현했다. 랜덤함수로 생각보다 다양한 기능에 활용될 수 있음을 깨달았다.

const searchPlaceholder = [
  '닭가슴살',
  '도시락',
  '현미떡',
  '프로틴음료',
  '떡볶이',
  '그릭요거트',
  '핫도그',
];

this.state = {
  searchPlaceholder:
  searchPlaceholders[
    Math.floor(Math.random() * searchPlaceholders.length)
  ],
};


header에는 장바구니 갯수를 가져오는 기능도 있는데, header와 cart는 분리된 컴포넌트이기 때문에 장바구니의 상태 변화시 헤더의 렌더링이 불가능하다. 이 문제를 해결하기 위해 window.location.을 사용하게 되었는데 뒤로 가기가 안되는 등 UX측면에서 안좋은 결과를 불러올 수 있다. 2차 프로젝트에서 함수형 컴포넌트와 리덕스 등을 사용하게 되면 좀 더 나은 방식으로 구현할 수 있을 듯하다.

FOOTER

처음에는 몰랐는데, 저녁에 다신샵의 상담 버튼을 눌러보니 alert창이 나타났다. 레이아웃만 만들면 될 것으로 생각했는데, 기능이 들어 있어 놀랐다. 시간 관련 함수는 처음 사용해봐서 기록해두고 싶다. 실제 서비스에는 시간 관련한게 굉장히 많기 때문에 앞으로도 유용하게 사용할 수 있을 것이다.

goToChat = () => {
  const now = new Date().getHours();
  if (now >= 12) {
    window.open('주소', '_blank');
  } else {
    alert('오후에만 팅팅탱탱 상담이 가능합니다( ⁎ ᵕᴗᵕ ⁎ )');
  }
};


지금 프로젝트에서 1:1 문의 게시판은 없지만 다신샵에서는 상담시간이 아닐 때 1:1 문의를 해달라고 alert가 뜬다. alert창을 클릭시 상단으로 이동하지 않도록 코드를 수정해야겠다.

goToChat 함수에 채팅 링크가 열리도록 작성했음에도 
html <a>태그가 사용되고 있음을 확인하고 <a>태그를 삭제해 해결했다. 

NAVBAR

지금까지 만들어본 레이아웃중에 가장 힘들었다. position 속성의 absolute와 relative를 적절히 활용해야 했다. 이 속성을 굉장히 많이 사용해 봤음에도 불구하고, 수정을 최소 100번 이상 반복하며 성공했기 때문에 어떤 방식이 맞는 것인지 조금 헷갈려 css 속성을 정리하고자 한다.

position:absolute는 부모요소의 position을 기준으로 움직이게 한다. 부모요소의 positiond은 상관이 없다. position:relative는 자신의 원래 위치에서 원하는 위치로 이동하고 싶을 때 사용한다.

/*드롭다운 navbar*/

.NavBar {
  position: sticky;
  top: 0px;
  
  .dropDownCategory {
    position: absolute;
    top: 52px;
  }
}

/*hover 반응형 navbar*/

.MainCategory {
  position: absolute; 
  z-index: 400;
  /* 이미지 캐러셀 위로 올라가도록 */

  .subMenu {
  	position: absolute;
    top: 0;
    left: 160px;
    height: 400px;
}


reset.css에서 box-sizing: border-box;을 확인하지 않고 진행해 맞지 않는 1px을 잡아내느라 수정에 수정을 반복하고 팀원들을 소환하기까지 했다. 문제는 바로 border-box..! 꼭 초기에 설정한 것을 확인하고 css 적용을 시작하자.

다신샵에서는 버튼을 클릭하면 이미지만 변경되지만, 다른 웹페이지에도 많은 캐러셀 기능을 구현해보고 싶었다. 캐러셀을 만들며 리액트에 다시 한 번 익숙해졌고, state의 개념을 다시 한 번 잡고 다른 기능들도 구현할 수 있었다. 아래 캐러셀 기능을 구현하며 createRef()라는 아주 편리한 기능을 하는 함수도 알게 되었다.

//createRef()사용법
1
constructor() {
  super();
  this.myRef: React.createRef(); 
}

2
<div className="spreadImage" ref={this.myRef}>
  
3
moveImage = () => {
  const moveImg = this.state.currentSlide - 1;
  this.myRef.current.style.transition = 'all 0.5s ease-in-out';
  this.myRef.current.style.transform = `translateX(-${moveImg}00vw)`;
};

이번 프로젝트에서는 적용하지 않았지만, Infinite Loop Carousel로 구현하려면 1번 이미지 앞에 5번 이미지를, 5번 이미지 뒤에 1번 이미지를 한 번 더 배치하는 방식으로도 시도해볼 수 있다.(state활용, 이미지 변경 후 setTimeout 지나가는 동안 state의 숫자를 바꿈) 추가로, setTimeout 함수로 사용자가 한 번 버튼을 클릭하면 일정 시간동안 애니메이션을 실행하고, 그 동안 버튼 클릭으로 이어지는 함수가 작동되지 않게 구현하면 좀 더 부드러운 애니메이션을 구현할 수도 있으니 참고하자.

COUNTDOWN

백엔드에서 업로드된 날짜에 10일을 더해 원하는 형식으로 받을 수 있게 만들었다. 프론트에서 날짜를 계산할까 하다가 쿼리문으로 간단하게 할 수 있다는 것을 알고 백엔드에서 처리했다.

DATE_FORMAT
(DATE_ADD(p.created_at, INTERVAL 10 DAY), '%m/%d/%Y') AS expireDate

getTime()은 1970 년 1 월 1 일 00:00:00 UTC와 주어진 날짜 사이의 경과 시간 (밀리 초)을 나타내는 숫자다. 이런 함수를 도대체 언제 이용할까 싶었는데, 카운트 다운 기능을 구현하며 사용 방법을 확실히 알게 되었다.

if (difference < 1) {
  this.setState({ timeUp: true });
  clearInterval(this.interval);
} else {
  let day = Math.floor(difference / (1000 * 60 * 60 * 24));
  let hour = Math.floor((difference / (1000 * 60 * 60)) % 24);
  let min = Math.floor((difference / (1000 * 60)) % 60);
  let sec = Math.floor((difference / 1000) % 60);
  this.setState({
    hour: hour > 9 ? hour : `0${hour}`,
    min: min > 9 ? min : `0${min}`,
    sec: sec > 9 ? sec : `0${sec}`,
    day,
  });
}

시, 분, 초가 한자리인 것보다 '01'과 같은 방식으로 표현되는 것이 좋을 것 같아 삼항연산자를 사용해 0이 추가되도록 했다. 기간이 만료된 경우, 함수 interval를 종료하고 props로 받은 '특가 행사가 종료되었습니다' 메세지를 보여줄 수 있도록 했다.

SIDEBAR

처음으로 로컬 스토리지에 대해 알았고, 데이터베이스에 저장되어야 할 필요가 없는 정보들을 잠시 담아두는데 활용하면 되겠다고 생각했다. 데이터를 어디에 저장할 것인지는 기획에 따라 달라지는데, 우리 프로젝트 기획에서는 장바구니 기능은 회원만 사용할 수 있기 때문에 데이터베이스에 담고 조회한 상품은 로컬 스토리지에 담기로 했다. 로컬 스토리지에 저장을 하고 읽는 함수는 생각보다 간단했지만, 객체에 같은 key로 넣게 되면 값을 덮어쓰는데 어떻게 해결을 할까 고민을 많이 했다. 고민을 하고 있던 중에 멘토님이 지나가시다가 고민을 듣고 배열이라는 힌트를 주셨고 구현에 성공했다.

localStorage 사용 방법!

//상품 상세페이지에 접속하면 localStorage에 상품의 id와 이미지를 저장한다. 
addToStorage = () => {
  const { id } = this.props.match.params;

  let loadedProduct = JSON.parse(localStorage.getItem('loadedProduct'));
  if (!loadedProduct) {
    loadedProduct = [];
    loadedProduct.unshift({
      productId: +id,
      imageUrl: this.state.img.image_url,
    });
    localStorage.setItem('loadedProduct', JSON.stringify(loadedProduct));
  } else if
    //loadedProduct의 길이가 16이상일 때 하나 삭제 후 추가(용량 관리)
    //같은 상품이 있는지 없는지 확인 후 없으면 추가, 있으면 삭제 후 추가
    
 //Sidebar에 가져오기
const sidebarData = JSON.parse(localStorage.getItem('loadedProduct'));

CHANGE IMAGE

데이터베이스에는 한 제품당 다섯 개의 이미지가 저장되고, 하나의 사진만 is_main이 true이다. 먼저 큰 이미지를 is_main이 true인 이미지로 두고, setInterval 함수를 사용해 3초에 한 번씩 사진이 변경되도록 했다.

componentDidMount에서 setInterval함수를 실행시켰다면, 반드시 componentWillUnmount에서 clearInterval로 함수를 종료시켜야 한다.

componentDidMount() {
  this.interval = setInterval(this.autoImageChange, 3000);
}

componentWillUnmount() {
  clearInterval(this.interval);
};

자동으로 이미지가 변경되는 동시에 hover를 하면 큰 이미지가 hover된 이미지가 변경되는 기능도 구현했다.

imageChange = url => {
  clearInterval(this.interval);
  this.setState({
    img: { image_url: url },
  });
  setTimeout((this.interval = setInterval(this.autoChangeImage, 3000)), 3000);
};

만약 hover하면 3초 뒤에다시 autoChangeImage함수가 실행될 수 있게 했다.

원래는 hover시 clearInterval을 하지 않았는데, autoImageChange가 실행된지 3초정도 지났다면 다른 이미지에 hover를 했을 때 변경이 잠시 되었다가 다시 다른 이미지로 변경되어 사용자의 불편함이 예상됐다. 따라서 interval을 껐다 3초 후에 다시 실행될 수 있도록 만들었다. 여전히 완벽한 방법이라고는 느껴지지 않아서 보다 편리하고 예쁘게 만들 수 있는 방법을 생각중이다.

버튼 클릭시 스크롤 이동

디테일 페이지에서 버튼 클릭시 해당 아이템의 위치로 이동하는 기능을 구현했다. 처음에는 같은 함수를 여러번 사용해서 코드가 불필요하게 길었다.

class ProductDescription extends React.Component {
  constructor() {
    super();
    this.descriptionRef = React.createRef();
    this.informationRef = React.createRef();
    this.reviewRef = React.createRef();
  }
  
  moveToRef = name => {
    let location = '';
    if (name === 'description') {
      location =
        this.descriptionRef.current.offsetTop + window.innerHeight;
    } else if (name === 'information') {
      location =
        this.informationRef.current.offsetTop + window.innerHeight;
    } else if (name === 'review') {
      location = this.reviewRef.current.offsetTop + window.innerHeight;
    }

    window.scroll({
      top: location,
      behavior: 'smooth',
    });

리팩토링 세션 시간에 ref도 객체로 묶어 사용할 수 있음을 알게 되었고, 반복되는 코드를 줄일 수 있었다. offsetTop에 window.innerHeight를 더한 이유는 클릭시 아이템이 가장 상단으로 오는 것이 아닌 브라우저 하단으로 왔기 때문이다. 세션에서 이를 해결할 수 있는 scrollIntoView()기능을 알게 되어 적용했다.

constructor() {
  super();
  this.multiRefs = {
    descriptionRef: React.createRef(),
    informationRef: React.createRef(),
    reviewRef: React.createRef(),
  };
}

moveToRef = name => {
  this.multiRefs[name].current.scrollIntoView({
    block: 'start',
    inline: 'nearest',
    behavior: 'smooth',
  });

여전히 스크롤시 navbar 아래 top:52px로 가게끔 하고 싶다는 아쉬움이 남지만, 기존의 코드보다 훨씬 가독성이 좋아졌고 예외없이 의도한대로 기능한다는 점에서 만족스럽다. 감사합니다 :)

클릭 이벤트에 따라 스크롤의 위치가 변경되어야 하는 기능을 구현할 때, 자식 컴포넌트의 특정 아이템으로 이동해야 한다면 forwarding Refs를 사용해야 한다. 이번 프로젝트에는 적용이 되지 않았지만, 다음에 사용할 기회가 있을 때 헤매지 않도록 링크를 남긴다.
https://ko.reactjs.org/docs/forwarding-refs.html

리스트 페이지 API

리스트 페이지는 다양한 endpoint에 따라 해당 조건에 맞는 상품들만 조회해야 한다.

router.get('/:sort', listController.getCategorizedProducts);

sort로 올 수 있는 종류는 다섯 가지다. 다섯 가지 모두 쿼리문이 동일하지만 마지막 한 줄(조건)만 달랐다. 쿼리문에 if문을 써보고, 객체로 묶어서 넣어봤지만 쿼리문이 동작하지 않아서 결국 초반에는 다섯 개의 함수를 작성해 model 파일의 코드 길이가 불필요하게 길었다. 아주 많이..

멘토님의 객체를 사용하라는 리뷰를 받고 다른 동기님에게 if문을 사용할 수 있는 방법에 대한 링크를 받았다. 바로바로!

import { Prisma } from '.prisma/client';

await prisma.$queryRaw`
  쿼리문
  쿼리문
  Prisma.sql`쿼리문`
`

Prisma.sql을 쿼리문 안에서 사용할 수 있다는 것이다.
해당링크 바로가기

아래는 긴 쿼리문을 생략한 대략적인 구조다.

const getProductsBySort = async sort => {
  const condition = {
    best: Prisma.sql`ORDER BY p.clicked DESC LIMIT 30`,
    new: Prisma.sql`ORDER BY p.created_at DESC LIMIT 30`,
    dashindelivery: Prisma.sql`WHERE`,
    cooldelivery: Prisma.sql`WHERE`,
    mainpage: Prisma.sql`WHERE`,
  };

  return await prisma.$queryRaw`
    SELECT 
      p.id, 
    FROM 
      products p
    ${
      sort === 'best'
        ? condition.best
        : sort === 'new'
        ? condition.new
        : sort === 'dashindelivery'
        ? condition.dashindelivery
        : sort === 'cooldelivery'
        ? condition.cooldelivery
        : sort === 'mainpage'
        ? condition.mainpage
        : Prisma.empty
    };
  `;
};

하지만 객체를 만들고, if문을 쿼리문 안에서 사용하려니 오류가 났다. 결국.. esLint에서 하지 말라고 하는 삼항연산자를 여러번 사용했다. 동작은 했지만 코드를 작성한 입장에서 굉장히 찝찝했다.

다시 동기님의 조언을 받았고, 변수를 선언하고 나서 if문을 사용하면 된다고 말씀해주셨다. 그 결과,

let query = '';
  if (sort === 'best') query = Prisma.sql`ORDER BY`;
  if (sort === 'new') query = Prisma.sql`ORDER BY`;
  if (sort === 'dashindelivery')
    query = Prisma.sql`WHERE`;
  if (sort === 'cooldelivery')
    query = Prisma.sql`WHERE`;
  if (sort === 'mainpage')
    query = Prisma.sql`WHERE`;

return await prisma.$queryRaw`
  SELECT 
  p.id, 
  FROM 
  products p
  ${query};
`

객체를 만들고 난뒤 삼항연산자를 사용하지 않으려면 if문을 어디서 사용해야 하나 하는 문제로 계속 고민을 했는데, 쿼리문 밖에서 사용하면 된다는 것을 드디어 알게 되었다. 이렇게 만드니 코드가 훨씬 직관적이고 깔끔해졌다. 감사합니다 멘토님 동기님,,ㅎㅎ

카테고리 API

테이블은 main_category와 sub_category 두 개로 나누어져 있고, mysql에서는 계층형으로 데이터를 요청할 수 없기 때문에 어떻게 해야하나 하는 고민이 컸다. 혼자 끙끙 앓으며 프론트 목데이터와 완벽하게 같은 구조를 만들어냈다.

//categoryDao.js
const getMainCategory = async () => {
  return await prisma.$queryRaw`
  SELECT id, name FROM main_categories ORDER BY id
  `;
}  
const subCategory = async () => {
  return await prisma.$queryRaw`
    SELECT * from sub_categories;
  `;
};

게다가 각 메인 카테고리별로 상품 하나의 이미지와 이름을 가져와야 했는데.. sql문으로만 해결하려니 정말 골치가 아팠다.

const getNewestProductOfEachCategory = async () => {
  await prisma.$queryRaw`
	SET sql_mode=(SELECT 	
	REPLACE(@@sql_mode,'ONLY_FULL_GROUP_BY',''))`;
  const newestProductOfEachCategory = await prisma.$queryRaw`
    select
    p.id, 
    p.name, 
    p.main_category_id,
    i.image_url
    from(
    select
    *
    from products
    where (main_category_id, created_at) in (
      select main_category_id, max(created_at) as created_at
              from products group by main_category_id
            )
    order by updated_at desc
    ) p
    JOIN products_thumbnails i
    ON i.product_id = p.id
    group by p.main_category_id
  `;

  return newestProductOfEachCategory;
};

GROUP BY가 제대로 작동하게 하려고 터미널에 sql_mode를 설정했음에도 포스트맨에서 오류가 나길래 쿼리문 실행 전에 무조건 설정을 바꾸겠다는 생각으로 임시방편이지만 설정을 하도록 했다. 코드를 쓰면서도 설정으로 해결하려는 생각은 정말 아닌 것 같다고 생각했다. 쿼리문을 작성하다 이미 지친 상태였기 때문에 후에 수정한다 해도 제발 한 번만 예쁜 결과를 보고 싶다는 생각이었다. 어리석었다..

심지어 서비스 단에서 서브카테고리와 이미지를 main_category_id에 맞게 분류해서 main_category 객체 안에 넣어주기까지 했다.

const groupBy = function (data, key) {
  return data.reduce(function (carry, el) {
    let group = el[key];

    if (carry[group] === undefined) {
      carry[group] = [];
    }

    carry[group].push(el);
    return carry;
  }, {});
};

const groupedSubCategory = groupBy(subCategory, 'main_category_id');

category.map((value, index) => {
  const mainCategoryId = (index + 1).toString();
  value.list = groupedSubCategory[mainCategoryId];
});

group_by 함수를 통해 main_category_id 별로 묶어주고, mainCategory를 돌며 list라는 key로 넣어준다. 결과적으로 원하는 데이터 구조를 만들어냈지만, 지금의 내가 일주일 전에 내가 쓴 코드를 이해하는데 시간이 걸리는 것을 보니 복잡해서 좋은 코드는 아니라는 생각이 다시 든다. 매우!

지금부터는 서비스 단에서 쿼리문을 여러번 돌리면 된다는 멘토 리뷰를 받고 찜찜함 없이 훨씬 깔끔해진 코드다.

const getCategory = async location => {
  const mainCategory = await productDao.getMainCategory();
  for (const category of mainCategory) {
    const subCategory = await productDao.getSubCategory(category.id);
    category.list = subCategory;
  }
  if (location === 'slider') {
    for (const category of mainCategory) {
      const newestProductsOfMainCategory =
        await productDao.getNewestProductOfEachCategory(category.id);
      category.product = newestProductsOfMainCategory;
    }
  }
  return mainCategory;
};

카테고리당 하나의 상품을 가져오는 쿼리문도 훨씬 보기 편해졌다.

SELECT
  p.id,
  p.name,
  p.main_category_id,
  pt.image_url
FROM
  products p
JOIN
  products_thumbnails pt
ON
  pt.product_id = p.id
WHERE
  p.main_category_id = ${mainCategoryId}
ORDER BY
  updated_at DESC LIMIT 1

서비스 단에서 반복문을 돌릴 때 주의해야 하는 점은 바로 '비동기'다.

참고링크
https://medium.com/@trustyoo86/async-await%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EB%B9%84%EB%8F%99%EA%B8%B0-loop-%EB%B3%91%EB%A0%AC%EB%A1%9C-%EC%88%9C%EC%B0%A8-%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0-315f31b72ccc

main_category를 돌며 각 카테고리에 대한 sub_category를 조회하는 코드를 짜려고 했을 때, map을 사용하는 것이 익숙해 시도했는데 1번만 쿼리문을 요청하고 그대로 반환했다. Array의 각 아이템을 비동기 처리해야하는 경우, 어떻게 해야할까? map함수 안에 여기저기 async await Promise.all 등을 붙여봤지만 제대로 실행되는 경우가 없었다. 막차시간이 되어 집에 가며 위의 블로그 글을 읽었고, array 반복을 병렬로 처리할 수 있는 for...of를 사용하기로 결정했다. (더 좋은 방법이 있으면 알려주세요 ㅎㅎ) array 내에서 비동기 처리해야하는 경우 for...of와 promise.all이 있다는 것을 기억하자!

데이터 입력 자동화 코드

금요일 발표를 앞두고 오류를 잡는데 시간을 쓰느라 바쁜 와중에 데이터 입력까지 해야 했다. 구글Docs를 사용해 팀원이 모두 모여 데이터를 모으니 금방 끝낼 수 있었고, csv파일로 만들어 자동으로 입력되게끔 하는 과정만 남아있었다. 빛소헌님의 세션 영상을 보고 우리팀 상황에 맞게 코드를 작성했다.

const InsertData = fileName => {
  const filePath = `dataUploader/csv/${fileName}.csv`;
  fs.createReadStream(filePath)
    .pipe(csv())
    .on('data', async data => {
      const sort = {
        mainCategory: Prisma.sql`INSERT INTO main_categories (id, name) VALUES (${data.id}, ${data.name})`,
        subCategory: Prisma.sql`INSERT INTO sub_categories...`,
        shipment: Prisma.sql`INSERT INTO shipments...`,
        productShipment: Prisma.sql`INSERT INTO products_shipments...`,
        productThumbnail: Prisma.sql`INSERT INTO products_thumbnails...`,
        product: Prisma.sql`INSERT INTO products...`,
        comment: Prisma.sql`INSERT INTO comments...`,
      };
      try {
        csvData.push(data);
        await prisma.$queryRaw`
          ${sort.productThumbnail}`; //2
      } catch (err) {
        console.log(err);
      }
    })
    .on('end', async () => {
      await console.log('csvData: ', csvData);
      prisma.$disconnect();
    });
};

InsertData('productThumbnail'); //1
//사용하기 위해서는 위 1,2를 수정해야한다 ㅠㅠ

아직 해결하지 못한 문제가 하나 있다. 바로 '비동기'!!! InsertData를 모든 테이블에 실행하려면 foreign-key때문에 무조건 categories => products => products_thumbnails 순서로 입력이 되어야 하는데, 여러가지 시도를 해보다가 결국 비동기 문제를 해결하지 못했다. 시간이 없었기 때문에 팀원들에게 InsertData의 인자를 셀프로 변경하고 여섯번 node를 각각 실행해달라고 부탁한 점이 굉장히 아쉽다. '자동화'인만큼 테이블의 모든 정보가 한번에 입력될 수 있었다면 더욱 편리했을 것이다. 또한 객체로 만든 것은 이전 list API를 작성할 때와 같은 실수다. if문을 사용했다면 더욱 간편했을 것이다. 비동기,,, 확실히 이해해서 다음에 코드 짤 때 '진짜' 자동화를 해보자..

리팩토링 세션에서 자바스크립트의 최신문법중 하나인 for await...of을 알게 되었다. 아래 블로그 글을 읽고 for await...of의 쓰임이 위 상황에 가장 적합할 것 같다고 예상했다.

async function insertAllData() {
  const files = 
        ['mainCategory', 'subCategory', 'product','shipment', 
         'products_thumbnails', 'products_shipments', 'comment'];
  
  for await(let file of files) {
  	await InsertData(file);
  }
}

insertAllData();

병렬적인 구조로 실행되어야 하고, 하나의 함수가 종료된 뒤의 다음 함수가 실행되어야 하면 for await...of 문법을 이용하는 것이 좋을 것 같다.

참고 블로그 : https://velog.io/@hiro2474/understandfor-await-of

좋은 웹페이지 즐겨찾기